repos / starfx

supercharged async flow control library.
git clone https://github.com/neurosnap/starfx.git

starfx / docs / posts
Eric Bower · 17 Aug 24

store.md

  1---
  2title: Store
  3description: An immutable store that acts like a reactive, in-memory database
  4---
  5
  6Features:
  7
  8- A single, global javascript object
  9- Reactive
 10- Normalized
 11- Acts like a database
 12
 13We love `redux`. We know it gets sniped for having too much boilerplate when
 14alternatives like `zustand` and `react-query` exist that cut through the
 15ceremony of managing state. However, `redux` was never designed to be easy to
 16use; it was designed to be scalable, debuggable, and maintainable. Yes, setting
 17up a `redux` store is work, but that is in an effort to serve its
 18maintainability.
 19
 20Having said that, the core abstraction in `redux` is a reducer. Reducers were
 21originally designed to contain isolated business logic for updating sections of
 22state (also known as state slices). They were also designed to make it easier to
 23sustain state immutability.
 24
 25Fast forward to `redux-toolkit` and we have `createSlice` which leverages
 26`immer` under-the-hood to ensure immutability. So we no longer need reducers for
 27immutability.
 28
 29Further, we argue, placing the business logic for updating state inside reducers
 30(via switch-cases) makes understanding business logic harder. Instead of having
 31a single function that updates X state slices, we have X functions (reducers)
 32that we need to piece together in our heads to understand what is being updated
 33when an action is dispatched.
 34
 35With all of this in mind, `starfx` takes all the good parts of `redux` and
 36removes the need for reducers entirely. We still have a single state object that
 37contains everything from API data, UX, and a way to create memoized functions
 38(e.g. [selectors](/selectors)). We maintain immutability (using
 39[immer](https://github.com/immerjs/immer)) and also have a middleware system to
 40extend it.
 41
 42Finally, we bring the utility of creating a schema (like [zod](https://zod.dev)
 43or a traditional database) to make it plainly obvious what the state shape looks
 44like as well as reusable utilities to make it easy to update and query state.
 45
 46This gets us closer to treating our store like a traditional database while
 47still being flexible for our needs on the FE.
 48
 49```ts
 50import { createSchema, createStore, select, slice } from "starfx";
 51
 52interface User {
 53  id: string;
 54  name: string;
 55}
 56
 57// app-wide database for ui, api data, or anything that needs reactivity
 58const [schema, initialState] = createSchema({
 59  cache: slice.table(),
 60  loaders: slice.loaders(),
 61  users: slice.table<User>(),
 62});
 63type WebState = typeof initialState;
 64
 65// just a normal endpoint
 66const fetchUsers = api.get<never, User[]>(
 67  "/users",
 68  function* (ctx, next) {
 69    // make the http request
 70    yield* next();
 71
 72    // ctx.json is a Result type that either contains the http response
 73    // json data or an error
 74    if (!ctx.json.ok) {
 75      return;
 76    }
 77
 78    const { value } = ctx.json;
 79    const users = value.reduce<Record<string, User>>((acc, user) => {
 80      acc[user.id] = user;
 81      return acc;
 82    }, {});
 83
 84    // update the store and trigger a re-render in react
 85    yield* schema.update(schema.users.add(users));
 86
 87    // User[]
 88    const users = yield* select(schema.users.selectTableAsList);
 89    // User
 90    const user = yield* select(
 91      (state) => schema.users.selectById(state, { id: "1" }),
 92    );
 93  },
 94);
 95
 96const store = createStore(schema);
 97store.run(api.register);
 98store.dispatch(fetchUsers());
 99```
100
101# How to update state
102
103There are **three** ways to update state, each with varying degrees of type
104safety:
105
106```ts
107import { updateStore } from "starfx";
108
109function*() {
110  // good types
111  yield* schema.update([/* ... */]);
112  // no types
113  yield* updateStore([/* ... */]);
114}
115
116store.run(function*() {
117  // no types
118  yield* store.update([/* ... */]);
119});
120```
121
122`schema.update` has the highest type safety because it knows your state shape.
123The other methods are more generic and the user will have to provide types to
124them manually.
125
126# Updater function
127
128`schema.update` expects one or many state updater functions. An updater function
129receives the state as a function parameter. Any mutations to the `state`
130parameter will be applied to the app's state using
131[immer](https://github.com/immerjs/immer).
132
133```ts
134type StoreUpdater<S extends AnyState> = (s: S) => S | void;
135```
136
137> It is highly recommended you read immer's doc on
138> [update patterns](https://immerjs.github.io/immer/update-patterns) because
139> there are limitations to understand.
140
141Here's a simple updater function that increments a counter:
142
143```ts
144function* inc() {
145  yield* schema.update((state) => {
146    state.counter += 1;
147  });
148}
149```
150
151Since the `update` function accepts an array, it's important to know that we
152just run those functions by iterating through that array.
153
154In fact, our store's core state management can _essentially_ be reduced to this:
155
156```ts
157import { produce } from "immer";
158
159function createStore(initialState = {}) {
160  let state = initialState;
161
162  function update(updaters) {
163    const nextState = produce(state, (draft) => {
164      updaters.forEach((updater) => updater(draft));
165    });
166    state = nextState;
167  }
168
169  return {
170    getState: () => state,
171    update,
172  };
173}
174```
175
176# Updating state from view
177
178You cannot directly update state from the view, users can only manipulate state
179from a thunk, endpoint, or a delimited continuation.
180
181This is a design decision that forces everything to route through our
182[controllers](/controllers).
183
184However, it is very easy to create a controller to do simple tasks like updating
185state:
186
187```ts
188import type { StoreUpdater } from "starfx";
189
190const updater = thunks.create<StoreUpdater[]>("update", function* (ctx, next) {
191  yield* updateStore(ctx.payload);
192  yield* next();
193});
194
195store.dispatch(
196  updater([
197    schema.users.add({ [user1.id]: user }),
198  ]),
199);
200```