Schema
Learn more about schamas and slices
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});