Why I built a new state management library
Ethan Standel 12 min read
Published 9.15.22 // Updated 9.30.22
Preact
React
TypeScript
JavaScript
NPM
State management
CQRS
DX
Performance
Cool tools
Update!
Since writing this article, I made the decision to rename and expand the library. The library described in this article was previously called preact-signal-store
and it worked in Preact applications only. However, the Preact team had supported React applications from the initial release of their signal primitive, using a monorepo model of three packages. They had @preact/signals-core
where the logic that defined signals and their core features lived, @preact/signals
which had the rendering logic and custom hooks specifically for Preact applications, and they had @preact-/signals-react
which had the rendering logic and custom hooks specifically for React applications.
When I first built preact-signal-store
, I only intended to support Preact applications with it, and so my library relied on @preact/signals
and exported a Preact specific hook. However, the first project that listed my library as a dependency on GitHub was a React project, and I really didn't like the idea of letting them down, so I decided to follow the pattern set by the Preact team. Initially, I thought I would make the original package name into an NPM scope. However, since the 2.0 release of preact-signal-store
, the name didn't fit it well. So I decided to make the scope name @deepsignal
, with underlying packages @deepsignal/core
, @deepsignal/preact
, and @deepsignal/react
!
Okay, I know what you're thinking...
Why another state management library? Haven't we all suffered enough having to learn Redux & MobX & Zustand & Jotai & Recoil & Valtio & XState? What possible use case or object pattern or mental model hasn't been covered yet? How could you be so arrogant as to think you could create something that's better than all of those?
- You, probably
I know because that's my reaction when I hear something like this, so I get it. But it's not like that, I swear. I was filling a gap in the market.
The introduction of the Preact Signal primitive
In early September, the Preact team made the announcement that they were officially releasing a new state primitive.
Wait, what's Preact again?
Preact is a library that is designed to be a faster implementation of React. It fulfills all the same core APIs as are exposed by React but in a more lightweight fashion.
Because this is the web, one of the first things you should do to get faster is get smaller.
The preact
package currently claims to bundle down to only 3kb of JS to function, whereas react
+ react-dom
requires upwards of 100kb.
Where React is built to be deployed with any of several rendering engines, most notably react-dom
& react-native
(and more recently @react-three/fiber
), Preact is just built for the web and that allows it to optimize strictly for that task.
It's worth noting however that Preact has always had several projects associated with it that are outside of the bounds of just being "P(erformant) React." However, its decision to closely support the React ecosystem has allowed it to grow to be one of the most popular modern frameworks at about 1.5 million installs weekly, at the time of this writing.
The new state primitive that they were supporting for both Preact & React is the "signal" model.
The signal model is designed to be a reactive observable for atomic state.
So that is to say it's a container for state which has no substate, that can be listened to and written to.
If you're familiar with React, then you might just say "that just sounds like useState
," and if you say that then you're kind of right.
The useState
hook accepts an initial atomic state, and returns a way to get the current state and update the state.
However, when the state declared from a useState
is updated, the component that the state was initialized in, as well as anything in its subtree that's not memoized, must be rerun.
That's a big part of how React works.
All state changes force the section of the application tree where the state was declared rerun and build up the virtual DOM (or VDOM).
At that point React identifies the differences between the currently rendered real DOM and the latest VDOM, in a process called diffing. It then cherry picks the things it needs to update in a process called reconciliation.
Preact also works this way, going through all of these steps for every new state update, but the new signal primitive provides an escape hatch from this behavior. Instead of binding direct values into the DOM that must be recalculated & reconciled, a signal will allow you to bind the signal itself (as opposed to the value) to the DOM which acts as a self-updating state container. So that gives you code that looks like this.
import { useSignal } from "@preact/signals";
const Counter = () => {
const count = useSignal(0);
console.log("Counter rendered");
return (
<>
<div>{count}</div>
<button onClick={() => count.value++}>
Increment
</button>
</>
);
}
In this example, you can see that the underlying value property can be written to directly by just reassigning it like a normal variable.
But what's bound as the children of the div
is not the value, but the signal itself.
The advantage this gives you against traditional React & Preact code is that no matter how many times you click the button to update that state, Counter rendered
will only log once because the signal is bound to the DOM and the component isn't listening to it.
This makes state updates far more performant and scale much better because they get to skip VDOM construction, diffing, and reconciliation.
And if you are in a situation where you need the signal to act more like a VDOM, then if you bind the value
of a signal into the VDOM then that component will subscribe to the signal just like useState
!
So it also has fallback behavior to continue to support the React ecosystem in full.
import { Input, Button } from "@mui/material";
import { signal } from "@preact/signals";
const field = signal("");
const Form = () => {
const onSubmit = (e: Event) => {
e.preventDefault();
alert(`Submitted value: ${field.peek()}`);
}
return (
<form onSubmit={onSubmit}>
<Input value={field.value} onInput={e => field.value = e.target.value} />
<Button type="submit">Submit</Button>
</form>
);
}
In the code above, for example, the @mui/material
package is built for React but is fully compatible with Preact applications.
Because this is a React package, it expects that the way data is managed around it follows a VDOM model.
By binding input.value
to the value
prop in the <Input />
component, the whole Form
component is now subscribed to changes to the input
signal, as if it were a regular useState
.
So when the onInput
event (which is equivalent to React's onChange
event) fires, Form
will rerun like a traditional VDOM driven component.
So Preact made a faster state container which is cool, but it actually gets better than that.
The signal primitive isn't inherently stuck in components like useState
.
You can initialize it globally and it acts exactly the same!
import { signal } from "@preact/signals";
const count = signal();
const Counter = () => (
<>
<div>{count}</div>
<button onClick={() => count.value++}>
Increment
</button>
</>
);
So suddenly, the new primitive fulfills a faster version of an old API and it allows for global atomic state. There's a lot of people who are pulling in a library like Jotai or Recoil to do this in React, but Preact now offers it as an officially supported primitive. Oh and there's also a React package too, but it does some things that a lot of people might consider worrisome for long term stability.
The gap in the market
I was initially really excited about signals, but the thing I couldn't get over was that it was for atomic state only, so no substates.
This design model felt like a golden opportunity for a highly performant full state management system, because it means that large state updates only have to update exactly what is required of them.
This kind of fine-grained updating is exactly what led Redux to offer a useSelector
hook, and for Zustand to offer a selector function as the argument when calling useStore
.
They were trying to encourage developers to only have to rely on the data that they need when pulling data out of state, but the fine-grained reactivity of being able to avoid the VDOM entirely can't really be matched for performance.
Initial implementation
It seemed like nobody had really jumped on this yet, so I took the opportunity! My initial mental model was very simplistic.
There would be two functions store
and destore
. The store
function would take in an object and convert all of its deeply nested atomic values into signals. So very simply this code...
import { store } from "preact-signal-store";
const userStore = store({
name: {
first: "Thor",
last: "Odinson"
},
email: "thor@avengers.org"
});
was equivalent to this code...
import { signal } from "@preact/signals";
const userStore = {
name: {
first: signal("Thor"),
last: signal("Odinson")
},
email: signal("thor@avengers.org")
};
and this code...
import { destore, store } from "preact-signal-store";
const userStore = destore(
store({
name: {
first: "Thor",
last: "Odinson"
},
email: "thor@avengers.org"
})
);
is equivalent to this code...
const userStore = {
name: {
first: "Thor",
last: "Odinson"
},
email: "thor@avengers.org"
};
So what I had created was a method of going into an object and finding every atomic property and turning it into a signal, as well as the ability to turn all of those signals back into their underlying values. But this, to me, still felt more like a valuable utility than a full state management system.
A new primitive for substate
After some criticism of language & titles in the library from another developer, the idea came to me that I could essentially give these "stores" the same API as signals already offer.
I had all the tools available, but I just had to place them into a sensible object model.
So if a Signal
is a holder of an atomic state, then it seemed natural to call a holder of substates a DeepSignal
.
So for version 2.0 of the library, there is just one main export, the deepSignal
function. So now this code...
import { deepSignal } from "preact-signal-store";
const userStore = deepSignal({
name: {
first: "Thor",
last: "Odinson"
},
email: "thor@avengers.org"
});
is equivalent to this code...
import { signal, batch } from "@preact/signals";
const userStore = {
name: {
first: signal("Thor"),
last: signal("Odinson"),
get value(): { first: string, last: string } {
return {
first: this.first.value,
last: this.last.value
}
},
set value(payload: { first: string, last: string }) {
batch(() => {
this.first.value = payload.first;
this.last.value = payload.last;
});
},
peek(): { first: string, last: string } {
return {
first: this.first.peek(),
last: this.last.peek()
}
},
},
email: signal("thor@avengers.org"),
get value(): { name: { first: string, last: string }, email: string } {
return {
name: {
first: this.name.first.value,
last: this.name.last.value
},
email: this.email.value
}
},
set value(payload: { name: { first: string, last: string }, email: string }) {
batch(() => {
this.name.first.value = payload.name.first;
this.name.last.value = payload.name.last;
this.email.value = payload.email;
});
},
peek(): { name: { first: string, last: string }, email: string } {
return {
name: {
first: this.name.first.peek(),
last: this.name.last.peek()
},
email: this.email.peek()
}
},
};
So this now builds the original concept of destore
and store
into one recursive model.
The advantage that this model provides to developers is that to the greatest extent possible, you no longer have to worry about things like where to put commonly updated state subscriptions or how to construct the most optimal selector function.
Now you just take data straight off a static looking object and you place it where you feel it should be placed.
And just as @preact/signals
exports both signal
and useSignal
, preact-signal-store
now exports deepSignal
and useDeepSignal
.
This makes for a version of state that's far more like class components with only a single object for all states in a component.
However, it continues to maintain the performance advantages of signals.
import { useDeepSignal } from "preact-signal-store";
const UserRegistrationForm = () => {
const { form, submitting } = useDeepSignal(() => ({
form: {
name: {
first: "",
last: ""
},
email: ""
},
submitting: false
}));
const submitRegistration = (event) => {
event.preventDefault();
submitting.value = true;
fetch(
"/register",
{ method: "POST", body: JSON.stringify(form.peek()) }
).finally(() => submitting.value = false);
}
return (
<form onSubmit={submitRegistration}>
<label>
First name
<input value={form.name.first}
onInput={e => form.name.first.value = e.currentTarget.value} />
</label>
<label>
Last name
<input value={form.name.last}
onInput={e => form.name.last.value = e.currentTarget.value} />
</label>
<label>
Email
<input value={form.email}
onInput={e => usformer.email.value = e.currentTarget.value} />
</label>
<button disabled={submitting}>Submit</button>
</form>
);
}
I would argue that this code aesthetic reads far more meaningfully than having to call useState
four different times and having to declare four different getters and four different setters, like you would have to in the example above.
Although people are accustomed to hooks and like them for many valid reasons, I think even simple examples of state management like this scale poorly with useState
, and push people towards libraries like formik
.
Conclusions
For anyone who has the initial reaction described in the beginning of this post, I hope that the reasons I've provided give some form of clarity as to why I decided to make a state management library. I hope the performance & developer experience gains justify its existence even if it is for a niche set of developers who are using Preact for Preact and not just as better React.
For more information on the preact-signal-store
library, go checkout the docs and please give it a shot and see what you think! And if you feel that there's a use-case that's missing, I'd love to consider expanding to meet more needs, so please file an issue!
And if you end up using the package and liking it, I'd be so appreciative if you would slap a star on the repo!
And even if you do none of that, thank you so much for reading!