starfx is a library I've been working on
for about a year. My plan is to make it the spiritual successor to
redux-saga. However, in that journey
I realized I also had to leave redux
behind.
The idea is still percolating, but I wanted to slowly start talking about it as
I finalize the API. This library is trying to accomplish a lot, but it will
essentially provide the functionality we enjoy with redux
, redux-saga
, and
rtk-query
but with a modern spin on side-effect and state management.
My current idea about this library is this: The FE community has been so focused
on state management to the point where side-effect management is an
afterthought. I want to flip that on its head with starfx
. Side-effect
management should be the first-class citizen of FE apps.
With that idea in mind, we have a set of abstractions that make managing side-effects -- including state mutations -- easy to read, write, and maintain.
At the core, we are leveraging two foundational libraries that help us with structured concurrency, using generators:
delimited continuations #
I recently gave a talk about delimited continuations where I also discuss this library:
starfx/examples
#
Want to skip the exposition and jump into code?
Check out our starfx-examples to play with it.
starfx
#
As we have seen with the success of redux-saga
, what we really want is the
ability to have a tree-like task structure to manage side-effects. Any I/O
operations greatly benefit from having complete control over asynchronous tasks
and redux-saga
is awesome at that because of structured concurrency.
So with effection
we get something that looks like redux-saga
but doesn't
need redux
at all to function.
The example currently in our README demonstrates that level of control we have
in starfx
:
1import { each, json, main, parallel, request } from "starfx";
2
3function* fetchChapter(title: string) {
4 const response = yield* request(`/chapters/${title}`);
5 const data = yield* json(response);
6 return data;
7}
8
9const task = main(function* () {
10 const chapters = ["01", "02", "03"];
11 const ops = chapters.map((title) => () => fetchChapter(title));
12
13 // parallel returns a list of `Result` type
14 const chapters = yield* parallel(ops);
15
16 // make http requests in parallel but process them in sequence (e.g. 01, 02, 03)
17 for (const result of yield* each(chapters.sequence)) {
18 if (result.ok) {
19 console.log(result.value);
20 } else {
21 console.error(result.error);
22 }
23 yield* each.next;
24 }
25});
26
27const results = await task;
28console.log(results);
So we have everything we need to express any async flow construct at the core of
this library. At least for FE apps, the next thing we need is a way to
intelligently let our view layer (e.g. react
) know when our data store
updates. This is where an immutable data store has been incredibly useful.
a wild state management lib appears #
I love redux
and nothing comes close to its ability to scale with such a
simple idea: reducers. I think it beats any other "data synchronization"
libraries out there (including react-query
), but there's a cost. The cost is a
strict structure for updating your data store as well as boilerplate. rtk
did
a lot to reduce boilerplate and move the community forward, but under-the-hood,
it's relying on immer
to provide immutability.
So I created a state management library for startfx
to give us an immutable
data store that doesn't require reducers.
1import { createStore, updateStore } from "starfx";
2const store = createStore({ initialState: { users: {} } });
3
4await store.run(function*() {
5 yield* updateStore((state) => {
6 state.users[1] = { id: "1", name: "bob" });
7 });
8});
Users are free to mutate state applying the same rules as immer
-- because
that's what we use. There's a koa-like middleware system as well for the store,
to provide similar features to redux
via middleware, with plans to inline
major ones like redux-persist
.
It's simpler than redux
but I think it still captures the essence of what
redux
provides: a way to structure state mutations so react
can be notified
of updates.
With simplifying state management, we are trying to create an API that resonates
with modern typescript. Instead of defining a reducer per slice in your store
and making sure they don't interact with each other, we want something that
looks more like a database schema. A single file where we can go and say "ah,
this is our state structure." The reason why this is so important is because
when you know what kind of data you have in your store, it makes understanding
the codebase much simpler. The store is king and we want a schematic for it.
Further, we took the idea of createSlice()
from rtk
and put some structure
around it. I've been using createSlice()
in production probably more than
anyone on this planet --
because I helped create it.
When building and structuring my data store for tons of web apps, there are very
common data structures:
table
a database table where the key is the primary key and the value is the rowloader
a database table but the records are decoupled loadersobj
a generic object with key valueslist
a list of anything -- usually strings for idsstr
num
With these I can pretty much build any modern FE web app. So when you define
your data store in starfx/store
, you provide a schema of these types:
1import { createSchema, slice } from "starfx";
2
3const emptyUser = { id: "", name: "" };
4export const schema = createSchema({
5 cache: slice.table({ empty: {} }),
6 loaders: slice.loader(),
7 users: slice.table({ empty: emptyUser }),
8 token: slice.str(),
9});
10export type AppState = typeof schema.initialState;
11/*
12{
13 users: Record<string, { id: string; name: string }>;
14 data: Record<string, unknown>;
15 loaders: Record<string, LoaderItemState>;
16}
17*/
When creating this schema, you get: the entire state type, initial state,
actions, and selectors. I won't dwell on this API too much but this is the area
I'm the most excited about with starfx
. I have to admit, the inspiration came
from the idea of stapling zod on top of
redux
. I've only just started playing with it, but I can tell already it's
going to be awesome. Pre-built data structures and a way to read and write them
is nice.
Like redux
, we leverage reselect
to create selectors and encourage
end-developers to use it as well, which is why we re-export createSelector
.
Similar to rtk
but philosophically different, we also have a way to manage
data synchronization.
createThunks
#
createThunks
is a koa-like middleware system hooked into activating
side-effects as well as our business logic:
1import { createThunks, run, sleep } from "starfx";
2
3const thunks = createThunks();
4thunks.use(thunks.routes());
5thunks.use(function* (ctx, next) {
6 console.log("start");
7 yield next();
8 console.log("all done!");
9});
10
11const increment = thunks.create("increment", function* (ctx, next) {
12 yield next();
13 console.log("waiting 1s");
14 yield sleep(1000);
15 console.log("incrementing!");
16});
17
18await run(increment());
19// start
20// waiting 1s
21// incrementing!
22// all done!
createApi
#
createApi
builds on top of createThunks
but restructures it for making HTTP
requests:
1import { createApi, createSchema, createStore, mdw, slice } from "starfx";
2
3const [schema, initialState] = createSchema({
4 loaders: slice.loader(),
5 data: slice.table(),
6});
7const store = createStore({ initialState });
8
9export const api = createApi();
10// mdw = middleware
11api.use(mdw.api({ schema }));
12api.use(api.routes());
13api.use(mdw.fetch({ baseUrl: "https://jsonplaceholder.typicode.com" }));
14
15export const fetchUsers = api.get<never, User[]>(
16 "/users",
17 { supervisor: takeEvery },
18 function* (ctx, next) {
19 yield* next();
20 if (!ctx.json.ok) {
21 return;
22 }
23
24 const users = ctx.json.data.reduce<Record<string, User>>((acc, user) => {
25 acc[user.id] = user;
26 return acc;
27 }, {});
28 yield* schema.update(db.users.add(users));
29 },
30);
31
32store.dispatch(fetchUsers());
react
#
This will fetch and automatically store the result, to be used inside react
:
1import { useDispatch, useSelector } from "starfx/react";
2import { AppState, fetchUsers, schema } from "./api.ts";
3
4function App({ id }: { id: string }) {
5 const dispatch = useDispatch();
6 const user = useSelector((s: AppState) => schema.users.selectById(s, { id }));
7 const userList = useSelector(schema.users.selectTableAsList);
8 return (
9 <div>
10 <div>hi there, {user.name}</div>
11 <button onClick={() => dispatch(fetchUsers())}>Fetch users</button>
12 {userList.map((u) => {
13 return <div key={u.id}>({u.id}) {u.name}</div>;
14 })}
15 </div>
16 );
17}
conclusion #
This probably feels overwhelming to read, but this isn't for simple website, it's designed for full-blown SPAs and it manages them very well. It has the right blend of abstraction in order to be productive immediately without feeling the weight of a large codebase.