Recently I migrated my side-project from Redux, which I’ve used in every React project for the past four years, to pullstate, here is my tale.

Problems with Redux

Feel free to skip this part if you already know the pain points of Redux, there’s nothing in here that hasn’t been discussed a million times before. These are the problems as I see them, most important first:

The boilerplate. Oh man, the boilerplate. Want to add a counter? First, add the field to your interface (I’m assuming TypeScript), add a default value, create a new action type for the incrementing, write an action creator, add the case to your reducer, add the dispatcher hook to your component, dispatch your action creator. It’s exhausting just to list the steps, nevermind actually doing it for every bit of state you need to keep track of.

Type-safety. Despite some valiant efforts to strong-arm the TypeScript type system to work with a stringly-typed API like Redux, the state of the art is still sorely lacking. If you go through all the hoops, you can get type-checking, at the expense of even more boilerplate.

Locality. I could go on a longer rant about the trade-off between de-coupling and locality, I’ll keep it to Redux. The symptom of the problem is that this:

<div
  onClick={() => {
    state.counter++;
  }}
/>

Which is easy to read, turns into this:

// component.tsx
<div
  onClick={() => {
    dispatch(incrementCounter());
  }}
/>;
// actionCreators.ts
export const incrementCounter = () => {
  return {
    type: AppAction.IncrementCounter,
  };
};
// reducer.ts
export const reducer = (state: AppState, action) => {
  // ...
  if (action.type == AppAction.IncrementCounter) {
    return {
      ...state,
      counter: state.counter + 1,
    };
  }
  // ...
};

This gets described as “de-coupling”, and “separation of concerns”. I think programmers saw the MVC pattern and the SOLID principles, then came to the conclusion that effects happening near their cause is a code smell. This is overly simplistic to the point of being wrong, and locality as a principle should get way more respect than it does; there’s value in having an effect close to its cause. Locality is the trade-off to de-coupling, and sometimes de-coupling needs to be called out as what it really is: indirection.

“But what if I’m doing something more complicated than incrementing a counter, or want to reuse code? I don’t want all state logic in my component”. Functions. What you’re looking for are functions.

The mutation trap. Accidentally mutate your state? Hope you didn’t need your UI to update.

The boilerplate. Did I mention the boilerplate?

Redux has good things too, otherwise I wouldn’t have used it for the past four years. It’s got a huge community, which has created loads of useful libraries. But at the core, its implementation of the Elm architecture just doesn’t translate well to JavaScript. With that venting out of the way…

Introducing Pullstate

Pullstate is a library built by lostpebble. As it describes itself:

Ridiculously simple state stores with performant retrieval anywhere in your React tree using the wonderful concept of React hooks!

I migrated my project over to pullstate in a fit of boilerplate frustration, and will never look back. By way of argument, I present the commit where I switched from Redux to pullstate. 163 lines deleted and 86 lines added, and that’s with more actual logic. Any library that cuts down my LoC by >50% wins big points. 160 lines of redux is obviously a tiny project. I also work on a web app with 1,000s of lines of Redux and I can’t see why pullstate wouldn’t scale to that project, if I could get a week or two off from more important tasks to work on it.

Boilerplate? Gone

Since this was the most satisfying part, I’ll highlight some of the bigger chunks that were removed:


Enum for action types? Gone.
- export enum AppAction {
-   AddInvestment = "AddInvestment",
-   UpdateInvestment = "UpdateInvestment",
-   ToggleSignInModal = "ToggleSignInModal",
-   InvestmentUploaded = "InvestmentUploaded",
-   Login = "Login",
- }

Reducer? Gone.
- export const appReducer = (state = defaultState, action) => {
-   // bunch of lines...
- };

C+P boilerplate to create a store? Gone.
- const makeStore: MakeStore<any> = (context: Context) =>
-   createStore(
-     appReducer,
-     // @ts-ignore
-     isSSR() ||
-       (window.__REDUX_DEVTOOLS_EXTENSION__ &&
-         window.__REDUX_DEVTOOLS_EXTENSION__())
-   );

Some higher-order-component nonsense? Gone.
- export const wrapper = createWrapper<AppState>(makeStore, { debug: true });

Passing my app into that higher-order component thing? Gone.
- export default wrapper.withRedux(MyApp);

With pullstate, this is all it takes to create a store:

export const AppStore = new Store<{
  houses: Investment[];
  // other fields...
}>({
  houses: [],
  // other fields...
});

No higher-order stuff I need to copy and paste for each project, just an exported store I can now use anywhere, contained in one file.

Reading from the store

Reading from the store looks very similar to Redux hooks:

import { AppStore } from "src/store";
// Inside component:
const initialized = AppStore.useState((s) => s.initialized);
// The redux equivalent:
// const initialized = useSelector((s: AppState) => s.initialized);

Not a big difference, but with redux I have to annotate the type to get TypeScript to type-check. Winner: pullstate by a hair.

Updating the store

This is where pullstate really shines:

<div
  onClick={() => {
    AppStore.update((s) => {
      s.houses.push(newHouse);
    });
  }}
/>

Pullstate uses immer when calling the update function, so there’s no need to worry about mutation. Modify your state how you would if you didn’t care about mutation. Type-checking also works inside the update function.

That’s it

In short, it’s a minimal API that covers everything I needed Redux to do but with more type-safety, less footguns, and none of the RSI-inducing boilerplate. Given how positive my experience switching to pullstate has been, I wish there was more of it to show. There’s more to it, but I haven’t used it, and my word count is at a nice round 1024 so I’ll leave it at that (yes, I did remove other stuff to keep it at 1024 after that note (and yes, I removed more stuff to have that bit of parenthisized clarification (also, yes, …))).