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);
<< PREV
Models
NEXT >>
Schema