DEV Community

Cover image for How to not regret useState
Mike Pearson for This is Learning

Posted on

How to not regret useState

YouTube

Features need to be able to evolve with unpredictable business needs. Features that are simple today are unlikely to remain simple.

useState is great for simple features. But when a feature needs to become more complex, how many developers are going to suddenly throw away all existing local state code and create a Redux store that takes 3x the amount of code? It's much easier to just add another line of imperative code to the mess.

The syntactic gap between imperative and declarative state management gradually steers the codebase towards a big ball of spaghetti that looks like the left version of this real example:

Imperative vs Declarative Diagram

Whereas, the fully unidirectional/reactive solution would have looked more like the version on the right.

The spaghetti codebase will be buggier and developers will be less productive in it. It is technical debt that has to be paid. The codebase has traveled along a certain path, and that path is no longer tenable. And in order to get out of it, we need to refactor to a completely different syntax. So we need to undo most of the work we've done, and start over on the right path. This is what I call a syntactic dead-end.

imperative-vs-declarative-paths

The only way to avoid these syntactic dead-ends is to use tools that enable minimal, declarative syntax at each level of complexity.

I have seen 8 levels of state management complexity:

  1. Simple Local State
  2. Complex State Changes
  3. Derived State
  4. Global State
  5. Reusable State Logic
  6. Global Events/Actions
  7. Asynchronous Events
  8. Combined Global Derived State

At each level we will see a fork in the road: Imperative vs Declarative. Let's look closely at each level and see what kinds of syntax could make the declarative path as easy as possible, so we can steer clear of syntactic dead-ends.

1. Simple Local State

Let's start with

function Component() {
  const [name, setName] = useState('Bob');
  return (
    <>
      <h2>Hello {name}!</h2>
      <button onClick={() => setName('Bilbo')}>Change Name</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

We've got name local state and we're setting it to 'Bilbo'. And we're using JSX, which is already declarative, so let's move on to the next level.

2. Complex State Changes

Let's say we need to add a button to reverse the name.

We need to choose between an imperative style and a declarative style. In the declarative style, all code that controls a piece of state is included in the state's declaration. The imperative style is the opposite of this, where state is controlled from inside event handlers away from the state's declaration.

Imperative vs declarative choice

2. Complex State Changes: Imperative

To solve this imperatively, we could add an event handler containing the state change logic:

function Component() {
  const [name, setName] = useState('Bob');
  const reverseName = () => {
    setName(n => n.split('').reverse().join(''));
  };
  return (
    <>
      <h2>Hello {name}!</h2>
      <button onClick={() => setName('Bilbo')}>Change Name</button>
      <button onClick={reverseName}>Reverse Name</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

2-Imperative

At this point we've already started down the path towards a syntactic dead-end. It looks harmless right now, but as the feature becomes more complex, that event handler will likely take on more and more responsibilities, and that state will be controlled by more and more event handlers. We will eventually have to remove the code we just wrote in order to get out of the mess. So let's not write it in the first place.

So how can we implement this declaratively? What tools allow us to include the state change logic in the state's declaration itself?

This is the problem useReducer was created to solve.

2. Complex State Changes: Declarative with useReducer

type Action = { type: 'reverse' } | { type: 'set'; payload: string };

function Component() {
  const [name, dispatch] = useReducer((state: string, action: Action) => {
    if (action.type === 'set') return action.payload;
    if (action.type === 'reverse') return state.split('').reverse().join('');
    return state;
  }, 'Bob');
  return (
    <>
      <h2>Hello {name}!</h2>
      <button onClick={() => dispatch({ type: 'set', payload: 'Bilbo' })}>
        Change Name
      </button>
      <button onClick={() => dispatch({ type: 'reverse' })}>Reverse Name</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

2-Declarative with useReducer

The benefit of this is that it is extremely similar to the syntax we'd use if we converted to a global Redux store later. It's declarative, meaning all logic that controls what this state will be is centralized in its declaration.

The drawback is that this is awful. There is so much work to add the syntax for this state change, it's no wonder most developers take the imperative path that eventually leads to spaghetti code.

Other tools are already disqualified as well. Redux is just like this, but even slightly worse. If our official policy was to use useReducer or Redux, most developers would constantly be pushing code into syntactic dead-ends instead of coding declaratively.

What we want is syntax that makes the declarative style just as easy as the imperative style.

Admittedly, I'm not an expert in every state management library in React, but after I couldn't find anything that already solved these problems for Angular apps I created my own solution and recently made it much easier to use in React. If you're aware of anything that's close to what I've created for React, please let me know. Regardless, whatever tool you use to solve this problem will have somewhat similar syntax, otherwise it wouldn't solve the problem.

2. Complex State Changes: Declarative with StateAdapt

function Component() {
  const [name, nameStore] = useAdapt(['name', 'Bob'], {
    reverse: state => state.split('').reverse().join(''),
  });
  return (
    <>
      <h2>Hello {name.state}!</h2>
      <button onClick={() => nameStore.set('Bilbo')}>Change Name</button>
      <button onClick={() => nameStore.reverse()}>Reverse Name</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

2-Declarative with StateAdapt

This is slightly more work than creating the imperative solution, but it's declarative, and it's much less code than useReducer.

Complex State Changes: Overview

3. Derived State

Let's say we want to display the name in all uppercase letters.

Declarative, derived state in React is trivial. All we have to do is add toUpperCase() to the JSX expression, and we have created some declarative, derived state.

function Component() {
  const [name, nameStore] = useAdapt(['name', 'Bob'], {
    reverse: state => state.split('').reverse().join(''),
  });
  return (
    <>
      <h2>Hello {name.state.toUpperCase()}!</h2>
      <button onClick={() => nameStore.set('Bilbo')}>Change Name</button>
      <button onClick={() => nameStore.reverse()}>Reverse Name</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

It gets more interesting in React when we need to actually share that derived state. In StateAdapt, we do this by adding a selectors object to our state changes object:

function Component() {
  const [name, nameStore] = useAdapt(['name', 'Bob'], {
    reverse: state => state.split('').reverse().join(''),
    selectors: {
      uppercase: state => state.toUpperCase(),
    },
  });
  return (
    <>
      <h2>Hello {name.uppercase}!</h2>
      <button onClick={() => nameStore.set('Bilbo')}>Change Name</button>
      <button onClick={() => nameStore.reverse()}>Reverse Name</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

So we have two choices. Is one of them a syntactic dead-end?

What if we choose the simple solution first? Will we regret it later? Here's all the work required when starting with local derived state first, then changing to StateAdapt's sharable derived state syntax:

3-Derived from local to StateAdapt

Actually, everything we did for pure local state was something we would have needed to do anyway to make it sharable. So we do not need to worry about syntactic dead-ends with derived state.

4. Global State

StateAdapt makes it very easy to move from local state to global state:

const nameStore = adapt(['name', 'Bob'], {
  reverse: state => state.split('').reverse().join(''),
  selectors: {
    uppercase: state => state.toUpperCase(),
  },
});

function Component() {
  const name = useStore(nameStore);
  return (
    <>
      <h2>Hello {name.uppercase}!</h2>
      <button onClick={() => nameStore.set('Bilbo')}>Change Name</button>
      <button onClick={() => nameStore.reverse()}>Reverse Name</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

4-Shared State with StateAdapt

Here's what we had to do:

  • Copy-paste the state declaration
  • Change useAdapt to adapt outside the component
  • Change useAdapt to useStore inside the component
  • Only define the nameStore outside
  • Only define name inside

That's easy enough to not regret having our state local at first. That's because declarative code is inherently more portable.

5. Reusable State Logic

Sometimes you not only need to share state, but also the logic that controls that state. For example, if you have one paginated datagrid and the designer produces a design that puts a 2nd datagrid of the exact same kind on the same page, you would want to reuse the state logic between the two separate instances of state.

This doesn't happen very often, but when it does, if your state logic is coupled to specific state and specific event sources, you are going to have a bad time.

In order to avoid this dead-end, you need to be putting all of your state logic from the beginning into a class or object that can easily be moved around independently. Here's how StateAdapt handles this scenario:

const nameAdapter = createAdapter<string>()({
  reverse: state => state.split('').reverse().join(''),
  selectors: {
    uppercase: state => state.toUpperCase(),
  },
});
const name1Store = adapt(['name1', 'Bob'], nameAdapter);
const name2Store = adapt(['name2', 'Bob'], nameAdapter);

function Component() {
  const name1 = useStore(name1Store);
  const name2 = useStore(name2Store);
  return (
    <>
      <h2>Hello {name1.uppercase}!</h2>
      <button onClick={() => name1Store.set('Bilbo')}>Change Name</button>
      <button onClick={() => name1Store.reverse()}>Reverse Name</button>

      <h2>Hello {name2.uppercase}!</h2>
      <button onClick={() => name2Store.set('Bilbo')}>Change Name</button>
      <button onClick={() => name2Store.reverse()}>Reverse Name</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

5-Adapters

This is as easy as it could possibly be.

6. Global Events/Actions

Let's say we need to add a button to reset each store back to its initial state.

Once again, we need to choose between an imperative style and a declarative style.

6. Global Events/Actions: Imperative

To solve this imperatively, we could add an event handler containing the state change logic:

const nameAdapter = createAdapter<string>()({
  reverse: state => state.split('').reverse().join(''),
  selectors: {
    uppercase: state => state.toUpperCase(),
  },
});

const name1Store = adapt(['name1', 'Bob'], nameAdapter);
const name2Store = adapt(['name2', 'Bob'], nameAdapter);

function resetBothNames() {
  name1Store.reset(); // `reset` comes with every store
  name2Store.reset();
}

function Component() {
  const name1 = useStore(name1Store);
  const name2 = useStore(name2Store);
  return (
    <>
      <h2>Hello {name1.uppercase}!</h2>
      <button onClick={() => name1Store.set('Bilbo')}>Change Name</button>
      <button onClick={() => name1Store.reverse()}>Reverse Name</button>

      <h2>Hello {name2.uppercase}!</h2>
      <button onClick={() => name2Store.set('Bilbo')}>Change Name</button>
      <button onClick={() => name2Store.reverse()}>Reverse Name</button>

      <button onClick={() => resetBothNames()}>Reset Both Names</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

6-Shared Sources Imperative

Again, we've started down the path towards a syntactic dead-end. As the feature becomes more complex, that event handler will take on more and more responsibilities, and that state will be controlled by more and more event handlers. We will eventually have to remove the code we just wrote in order to get out of the mess. So let's not write it in the first place.

So how can we implement this declaratively?

6. Global Events/Actions: Declarative with StateAdapt

The reason Redux is declarative is because each reducer/state declares for itself which actions its interested in and how its state reacts to them. State is downstream from actions.

Redux is reactive

StateAdapt uses RxJS to enable the exact same kind of declarative state management. You can define a Source, which is like an RxJS Subject but emits Action objects similar to those in Redux. And stores are able to react to these action sources, just like in Redux:

const nameAdapter = createAdapter<string>()({
  reverse: state => state.split('').reverse().join(''),
  selectors: {
    uppercase: state => state.toUpperCase(),
  },
});

const resetBothNames$ = new Source<void>('resetBothNames$'); // action type

const name1Store = adapt(['name1', 'Bob', nameAdapter], {
  reset: resetBothNames$,
});
const name2Store = adapt(['name2', 'Bob', nameAdapter], {
  reset: resetBothNames$,
});

function Component() {
  const name1 = useStore(name1Store);
  const name2 = useStore(name2Store);
  return (
    <>
      <h2>Hello {name1.uppercase}!</h2>
      <button onClick={() => name1Store.set('Bilbo')}>Change Name</button>
      <button onClick={() => name1Store.reverse()}>Reverse Name</button>

      <h2>Hello {name2.uppercase}!</h2>
      <button onClick={() => name2Store.set('Bilbo')}>Change Name</button>
      <button onClick={() => name2Store.reverse()}>Reverse Name</button>

      <button onClick={() => resetBothNames$.next()}>Reset Both Names</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

6-Shared Events/Actions StateAdapt

This is just 1 more line of code than the imperative solution, so it should be easy to stay declarative with this syntax.

7. Asynchronous Events

Now let's say we want to load the names from the server. The name from the server can be declared as an observable, and StateAdapt provides an RxJS operator called toSource that converts an observable into a source.

const nameAdapter = createAdapter<string>()({
  reverse: state => state.split('').reverse().join(''),
  selectors: {
    uppercase: state => state.toUpperCase(),
  },
});

const resetBothNames$ = new Source<void>('resetBothNames$');

const name$ = timer(3_000).pipe( // Pretend it's from the server
  map(() => 'Bob'),
  toSource('name$'),
);

const name1Store = adapt(['name1', 'Loading', nameAdapter], {
  set: name$,
  reset: resetBothNames$,
});
const name2Store = adapt(['name2', 'Loading', nameAdapter], {
  set: name$,
  reset: resetBothNames$,
});

function Component() {
  const name1 = useStore(name1Store);
  const name2 = useStore(name2Store);
  return (
    <>
      <h2>Hello {name1.uppercase}!</h2>
      <button onClick={() => name1Store.set('Bilbo')}>Change Name</button>
      <button onClick={() => name1Store.reverse()}>Reverse Name</button>

      <h2>Hello {name2.uppercase}!</h2>
      <button onClick={() => name2Store.set('Bilbo')}>Change Name</button>
      <button onClick={() => name2Store.reverse()}>Reverse Name</button>

      <button onClick={() => resetBothNames$.next()}>Reset Both Names</button>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

7-Asynchronous Events

Asynchronous side-effects and events are where most state management libraries stop being declarative. But RxJS allows StateAdapt to let the stores define their own behavior over time.

8. Combined Global Derived State

Now let's say we want to show a message "Hello Bobs!" when both names are 'Bob'. So we want derived state that is true when both names are Bob, and we want to be able to use this in multiple components.

The syntactic dead-end for this feature is using RxJS. We need RxJS for declarative asynchronous logic, but it is not great for synchronously derived state, as I explain here.

In StateAdapt, all stores are part of a single global store, so we use selectors to efficiently compute derived data between stores:

const name12Store = joinStores({
  name1: name1Store,
  name2: name2Store,
})({
  bothBobs: s => s.name1 === 'Bob' && s.name2 === 'Bob',
})();
// ...
  const name12 = useStore(name12Store);
  // ...
      {name12.bothBobs && <h2>Hello Bobs!</h2>}
Enter fullscreen mode Exit fullscreen mode

8-Combined Shared Derived State

This isn't as concise as it could be for defining only one selector, but the syntax helps avoid a different kind of syntactic dead-end that's a little too obscure to talk about here :) For more information, see the documentation for joinStores.

Conclusion

Here is how the code may have looked if we had taken the imperative path at every step:

Imperative Jotai vs Declarative StateAdapt

(I used Jotai in the imperative example, but I'm sure Jotai can be used more reactively.)

Now, ask yourself, "What is the value of name1?" In the declarative implementation you can use "Click to Definition" until you get your answer; everything is centralized and directly referencing what it needs to define itself. But in the imperative implementation you have to use "Find all References" because name1 is controlled from many places.

This is a very simple example. In real-world applications the imperative style becomes much harder to follow.

Like many developers, I'm too lazy to implement this in Redux. But you can imagine that it would have been quite a bit more code, and as a consequence, the odds that a developer would have taken the imperative path at some point would have been very high.

So, for almost all teams, it seems that adaptive state management is necessary for clean, maintainable code.


StateAdapt is very close to releasing version 1.0. I would love to hear what you think about it.

If there are any state patterns you think I'm missing in this article, let me know.

Top comments (0)