Store
An immutable store that acts like a reactive, in-memory database
Features:
- A single, global javascript object
- Reactive
- Normalized
- Acts like a database
We love redux
. We know it gets sniped for having too much boilerplate when
alternatives like zustand
and react-query
exist that cut through the
ceremony of managing state. However, redux
was never designed to be easy to
use; it was designed to be scalable, debuggable, and maintainable. Yes, setting
up a redux
store is work, but that is in an effort to serve its
maintainability.
Having said that, the core abstraction in redux
is a reducer. Reducers were
originally designed to contain isolated business logic for updating sections of
state (also known as state slices). They were also designed to make it easier to
sustain state immutability.
Fast forward to redux-toolkit
and we have createSlice
which leverages
immer
under-the-hood to ensure immutability. So we no longer need reducers for
immutability.
Further, we argue, placing the business logic for updating state inside reducers (via switch-cases) makes understanding business logic harder. Instead of having a single function that updates X state slices, we have X functions (reducers) that we need to piece together in our heads to understand what is being updated when an action is dispatched.
With all of this in mind, starfx
takes all the good parts of redux
and
removes the need for reducers entirely. We still have a single state object that
contains everything from API data, UX, and a way to create memoized functions
(e.g. selectors). We maintain immutability (using
immer) and also have a middleware system to
extend it.
Finally, we bring the utility of creating a schema (like zod or a traditional database) to make it plainly obvious what the state shape looks like as well as reusable utilities to make it easy to update and query state.
This gets us closer to treating our store like a traditional database while still being flexible for our needs on the FE.
1import { createSchema, createStore, select, slice } from "starfx";
2
3interface User {
4 id: string;
5 name: string;
6}
7
8// app-wide database for ui, api data, or anything that needs reactivity
9const [schema, initialState] = createSchema({
10 cache: slice.table(),
11 loaders: slice.loaders(),
12 users: slice.table<User>(),
13});
14type WebState = typeof initialState;
15
16// just a normal endpoint
17const fetchUsers = api.get<never, User[]>(
18 "/users",
19 function* (ctx, next) {
20 // make the http request
21 yield* next();
22
23 // ctx.json is a Result type that either contains the http response
24 // json data or an error
25 if (!ctx.json.ok) {
26 return;
27 }
28
29 const { value } = ctx.json;
30 const users = value.reduce<Record<string, User>>((acc, user) => {
31 acc[user.id] = user;
32 return acc;
33 }, {});
34
35 // update the store and trigger a re-render in react
36 yield* schema.update(schema.users.add(users));
37
38 // User[]
39 const users = yield* select(schema.users.selectTableAsList);
40 // User
41 const user = yield* select(
42 (state) => schema.users.selectById(state, { id: "1" }),
43 );
44 },
45);
46
47const store = createStore(schema);
48store.run(api.register);
49store.dispatch(fetchUsers());
How to update state #
There are three ways to update state, each with varying degrees of type safety:
1import { updateStore } from "starfx";
2
3function*() {
4 // good types
5 yield* schema.update([/* ... */]);
6 // no types
7 yield* updateStore([/* ... */]);
8}
9
10store.run(function*() {
11 // no types
12 yield* store.update([/* ... */]);
13});
schema.update
has the highest type safety because it knows your state shape.
The other methods are more generic and the user will have to provide types to
them manually.
Updater function #
schema.update
expects one or many state updater functions. An updater function
receives the state as a function parameter. Any mutations to the state
parameter will be applied to the app's state using
immer.
1type StoreUpdater<S extends AnyState> = (s: S) => S | void;
It is highly recommended you read immer's doc on update patterns because there are limitations to understand.
Here's a simple updater function that increments a counter:
1function* inc() {
2 yield* schema.update((state) => {
3 state.counter += 1;
4 });
5}
Since the update
function accepts an array, it's important to know that we
just run those functions by iterating through that array.
In fact, our store's core state management can essentially be reduced to this:
1import { produce } from "immer";
2
3function createStore(initialState = {}) {
4 let state = initialState;
5
6 function update(updaters) {
7 const nextState = produce(state, (draft) => {
8 updaters.forEach((updater) => updater(draft));
9 });
10 state = nextState;
11 }
12
13 return {
14 getState: () => state,
15 update,
16 };
17}
Updating state from view #
You cannot directly update state from the view, users can only manipulate state from a thunk, endpoint, or a delimited continuation.
This is a design decision that forces everything to route through our controllers.
However, it is very easy to create a controller to do simple tasks like updating state:
1import type { StoreUpdater } from "starfx";
2
3const updater = thunks.create<StoreUpdater[]>("update", function* (ctx, next) {
4 yield* updateStore(ctx.payload);
5 yield* next();
6});
7
8store.dispatch(
9 updater([
10 schema.users.add({ [user1.id]: user }),
11 ]),
12);