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:
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.
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:
- Simple Local State
- Complex State Changes
- Derived State
- Global State
- Reusable State Logic
- Global Events/Actions
- Asynchronous Events
- 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>
</>
);
}
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.
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>
</>
);
}
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>
</>
);
}
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>
</>
);
}
This is slightly more work than creating the imperative solution, but it's declarative, and it's much less code than useReducer
.
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>
</>
);
}
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>
</>
);
}
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:
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>
</>
);
}
Here's what we had to do:
- Copy-paste the state declaration
- Change
useAdapt
toadapt
outside the component - Change
useAdapt
touseStore
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>
</>
);
}
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>
</>
);
}
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.
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>
</>
);
}
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>
</>
);
}
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>}
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:
(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)