what is starfx?

· erock's devlog

a modern approach to side-effect and state-management

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:

Delimited continuations are all you need

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:

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.

Read the docs


I have no idea what I'm doing. Subscribe to my rss feed to read more posts.