Schema

Learn more about schamas and slices


Table of Contents

A schema has two primary features:

  • A fully typed state shape
  • Reusable pieces of state management logic

A schema must be an object. It is composed of slices of state. Slices can represent any data type, however, we recommend keeping it as JSON serializable as possible. Slices not only hold a value, but with it comes some handy functions to:

  • Update the value
  • Query for data within the value

Our schema implementation was heavily inspired by zod.

# Schema assumptions

createSchema requires two slices by default in order for it and everything inside starfx to function properly: cache and loaders.

Why do we require those slices? Because if we can assume those exist, we can build a lot of useful middleware and supervisors on top of that assumption. It's a place for starfx and third-party functionality to hold their state.

1import { createSchema, slice } from "starfx";
2
3const [schema, initialState] = createSchema({
4  cache: slice.table(),
5  loaders: slice.loaders(),
6});

# Built-in slices

As a result, the following slices should cover the most common data types and associated logic.

# slice.any

This is essentially a basic getter and setter slice. You can provide the type it ought to be and it has a couple functions to manage and query the value stored inside of it.

1const [schema] = createSchema({
2  nav: slice.any<bool>(false),
3});
4
5function*() {
6  yield* schema.update(schema.nav.set(true)); // set the value
7  const nav = yield* select(schema.nav.select); // grab the value
8  yield* schema.update(schema.nav.reset()); // reset value back to inititial
9}

# num

This slice has some custom actions to manage a number value.

 1const [schema] = createSchema({
 2  views: slice.num(0),
 3});
 4
 5function*() {
 6  yield* schema.update(schema.views.increment());
 7  yield* schema.update(schema.views.decrement());
 8  yield* schema.update(schema.views.set(100));
 9  const views = yield* select(schema.views.select);
10  yield* schema.update(schema.views.reset()); // reset value back to inititial
11}

# str

This slice is probably not super useful since it is essentially the same as slice.any<string> but we could add more actions to it in the future.

1const [schema] = createSchema({
2  token: slice.str(""),
3});
4
5function*() {
6  yield* schema.update(schema.token.set("1234"));
7  const token = yield* select(schema.token.select);
8  yield* schema.update(schema.token.reset()); // reset value back to inititial
9}

# obj

This is a specialized slice with some custom actions to deal with javascript objects.

 1const [schema] = createSchema({
 2  settings: slice.obj({
 3    notifications: false,
 4    theme: "light",
 5  }),
 6});
 7
 8function*() {
 9  yield* schema.update(schema.settings.update(theme, "dark"));
10  yield* schema.update(schema.settings.update(notifications, true));
11  const settings = yield* select(schema.settings.select);
12  yield* schema.update(schema.token.reset()); // reset value back to inititial
13  yield* schema.update(
14    schema.token.set({ notifications: true, theme: "dark" }),
15  );
16}

# table

This is the more powerful and specialized slice we created. It attempts to mimick a database table where it holds an object:

1type Table<Entity = any> = Record<string | number, Entity>;

The key is the entity's primary id and the value is the entity itself.

 1const [schema] = createSchema({
 2  users: slice.table({ empty: { id: "", name: "" } }),
 3});
 4
 5function*() {
 6  const user1 = { id: "1", name: "bbob" };
 7  const user2 = { id: "2", name: "tony" };
 8  const user3 = { id: "3", name: "jessica" };
 9  yield* schema.update(
10    schema.users.add({
11      [user1.id]: user1,
12      [user2.id]: user2,
13      [user3.id]: user3,
14    }),
15  );
16  yield* schema.update(
17    schema.users.patch({ [user1.id]: { name: "bob" } }),
18  );
19  yield* schema.update(
20    schema.users.remove([user3.id]),
21  );
22  
23  // selectors
24  yield* select(schema.users.selectTable());
25  yield* select(schema.users.selectTableAsList());
26  yield* select(schema.users.selectById({ id: user1.id }));
27  yield* select(schema.users.selectByIds({ ids: [user1.id, user2.id] }));
28
29  yield* schema.update(schema.users.reset());
30}

# empty

When empty is provided to slice.table and we use a selector like selectById to find an entity that does not exist, we will return the empty value.

This mimicks golang's empty values but catered towards entities. When empty is provided, we guarentee that selectById will return the correct state shape, with all the empty values that the end-developer provides.

By providing a "default" entity when none exists, it promotes safer code because it creates stable assumptions about the data we have when performing lookups. The last thing we want to do is litter our view layer with optional chaining, because it sets up poor assumptions about the data we have.

Read more about this design philosophy in my blog post: Death by a thousand existential checks.

When creating table slices, we highly recommend providing an empty value.

Further, we also recommend creating entity factories for each entity that exists in your system.

Read more about entity factories.

# loaders

This is a specialized database table specific to managing loaders in starfx. Read more about loaders here.

 1const [schema] = createSchema({
 2  loaders: slice.loaders(),
 3});
 4
 5function*() {
 6  yield* schema.update(schema.loaders.start({ id: "my-id" }));
 7  yield* schema.update(schema.loaders.success({ id: "my-id" }));
 8  const loader = yield* select(schema.loaders.selectById({ id: "my-id" }));
 9  console.log(loader);
10}

# Build your own slice

We will build a counter slice to demonstrate how to build your own slices.

 1import type { AnyState } from "starfx";
 2import { BaseSchema, select } from "starfx/store";
 3
 4export interface CounterOutput<S extends AnyState> extends BaseSchema<number> {
 5  schema: "counter";
 6  initialState: number;
 7  increment: (by?: number) => (s: S) => void;
 8  decrement: (by?: number) => (s: S) => void;
 9  reset: () => (s: S) => void;
10  select: (s: S) => number;
11}
12
13export function createCounter<S extends AnyState = AnyState>(
14  { name, initialState = 0 }: { name: keyof S; initialState?: number },
15): CounterOutput<S> {
16  return {
17    name: name as string,
18    schema: "counter",
19    initialState,
20    increment: (by = 1) => (state) => {
21      (state as any)[name] += by;
22    },
23    decrement: (by = 1) => (state) => {
24      (state as any)[name] -= by;
25    },
26    reset: () => (state) => {
27      (state as any)[name] = initialState;
28    },
29    select: (state) => {
30      return (state as any)[name];
31    },
32  };
33}
34
35export function counter(initialState?: number) {
36  return (name: string) => createCounter<AnyState>({ name, initialState });
37}
38
39const [schema, initialState] = createSchema({
40  counter: counter(100),
41});
42const store = createStore(initialState);
43
44store.run(function* () {
45  yield* schema.update([
46    schema.counter.increment(),
47    schema.counter.increment(),
48  ]);
49  const result = yield* select(schema.counter.select);
50  console.log(result); // 102
51});
<< PREV
Store
NEXT >>
Selectors