Stop managing state

Mike Pearson - Jan 5 '23 - - Dev Community

YouTube

Have you ever written state management that looked like this?

return ({
  ...state,
  checked: !state.checked,
});
Enter fullscreen mode Exit fullscreen mode

You've probably done this a million times.

But you won't have to anymore, thanks to state adapters.

What are state adapters?

State adapters are objects that contain reusable logic for changing and selecting from state. Each state adapter is dedicated to a single state type/interface, which enables portability and reusability. Everywhere you need to manage state with a certain shape, you can use its state adapter.

If someone just made a boolean state adapter, nobody on Earth would ever need to write that code again. Instead, they could just write something like this:

return adapter.toggleChecked(state);
Enter fullscreen mode Exit fullscreen mode

Booleans are the simplest type of state, but even in the case of booleans, being able to reuse state management logic is very nice.

Decoupled state management, without the boilerplate

Most state management code is coupled to specific state. For example, in Redux or NgRx apps you will see state management like this:

on(TodoActions.createSuccess, (state, { todo }) => ({
  ...state,
  todos: [...state.todos, todo],
  loading: false,
})),
Enter fullscreen mode Exit fullscreen mode

The action createSuccess has specific event sources dispatching it, and this reducer is managing specific state. So all the state logic defined in this code is coupled to that specific state and that specific action, and it takes a fair amount of work to decouple it.

Imagine if object-oriented programming didn't support the new key word. Every class you defined would actually be a specific object, not just some abstraction that could be used as over and over again with different data. How would people reuse any logic? They would define it externally, and import it into each class that needed it. But then the business logic isn't in the class anymore. The class turns into nothing more than a slab of boilerplate.

Does that sound familiar? This might remind you of reducers in Redux/NgRx. And extracting the logic into utilities is exactly what these libraries have done with their entity adapters. These provide some utilities for handling common state management patters, like this:

on(TodoActions.createSuccess, (state, { todo }) =>
  adapter.addOne(todo, { ...state, loading: false })
),
Enter fullscreen mode Exit fullscreen mode

But this only handles some of the logic; the remaining logic is still coupled to this specific reducer.

What if we had an asyncEntityAdapter?

on(TodoActions.createSuccess, (state, { todo }) =>
  adapter.addOneResolve(todo, state)
),
Enter fullscreen mode Exit fullscreen mode

That's better.

Now we just have the reducer boilerplate left.

What if we just always wrote our state management logic inside adapters where it would be decoupled from any specific reducer? Our reducers would then only ever be calling adapter methods. And we could remove some boilerplate if the arguments of those adapter methods were switched and we could just reference the method:

on(TodoActions.createSuccess, adapter.addOneResolve),
Enter fullscreen mode Exit fullscreen mode

Now our reducer contains almost no boilerplate. If we want to get rid of all of it, we should be able to define our adapter code inside the reducer function itself, and only when needed, extract it by cutting and pasting the code to the outside where it can be used by multiple reducers. So the syntax for defining reducers and state adapters should be the same. But Redux and NgRx can't do this and don't provide any utilities for developers to easily define state adapter logic themselves, so we will look into some simple utilities we can provide ourselves later.

First, let's explore state adapters themselves and other ways in which they might be valuable.

Composability

State is usually composed of smaller types/interfaces, like this:

interface OptionState {
  value: string;
  checked: boolean;
}
Enter fullscreen mode Exit fullscreen mode

In the first example above we saw that even a boolean state adapter could be valuable. And what if we also had a stringAdapter? And if we had those two, could we somehow define an optionAdapter that is composed of those two adapters, just as the OptionState interface is composed using the string and boolean types?

Let's say we have a utility that allows us to define adapters with type inference. Our booleanAdapter could be defined like this:

export const booleanAdapter = createAdapter<boolean>()({
  setTrue: () => true,
  setFalse: () => false,
  toggle: state => !state,
});
Enter fullscreen mode Exit fullscreen mode

Imagine a similar definition for stringAdapter.

Now how do we define an optionAdapter composed of these two adapters? We could just create a new adapter and use the child adapters directly:

export const optionAdapter = createAdapter<Option>()({
  setValue: (state, value: string) => ({ ...state, value }),
  toggleChecked: state => ({
    ...state,
    checked: booleanAdapter.toggle(state.checked),
  }),
});
Enter fullscreen mode Exit fullscreen mode

But booleanAdapter.toggle(state.checked) could have just been !state.checked. So this actually sucks.

So I came up with a function to make it easier to compose adapters:

export const optionAdapter = joinAdapters<Option>()({
  value: baseStringAdapter,
  checked: booleanAdapter,
})();
Enter fullscreen mode Exit fullscreen mode

That's all it takes, and in the end we get an object like this:

{
  set: (state: Option, payload: Option) => payload,
  update: (state: Option, payload: Partial<Option>) => ({ ...state, ...payload }),
  reset: (s: Option, p: void, initialState: Option) => initialState,
  setValue: (state: Option, value: string) => ({ ...state, value }),
  resetValue: (state: Option, p: void, initialState: Option) => ({
    ...state,
    value: initialState.value,
  }),
  setChecked: (state: Option, checked: boolean) => ({ ...state, checked }),
  resetChecked: (state: Option, p: void, initialState: Option) => ({
    ...state,
    checked: initialState.checked,
  }),
  setCheckedTrue: (state: Option) => ({ ...state, checked: true }),
  setCheckedFalse: (state: Option) => ({ ...state, checked: false }),
  toggleChecked: (state: Option) => ({ ...state, checked: !state.checked }),
  selectors: {
    value: (state: Option) => state.value,
    checked: (state: Option) => state.checked,
  }
}
Enter fullscreen mode Exit fullscreen mode

See how booleanAdapter.setTrue became optionAdapter.setCheckedTrue? joinAdapters assumes that each state change name will start with a verb. I thought about putting the namespace first, like checkedToggle and checkedSetTrue, but in some cases that gets confusing. Inserting the namespace after the first word is a small computational cost, but it makes the names clearer. And as of TypeScript 4.1, these types of string transformations are possible while keeping type inference intact. So it will warn you if you try to pass a payload into optionAdapter.setCheckedTrue and it will make sure you pass a boolean into optionAdapter.setChecked.

If state from multiple child adapters needs to change at the same time, joinAdapters has a way to let you define that so it happens efficiently. It also has a way to let you define memoized selectors that combine state from multiple child adapters. You can read more about these adapter patterns here.

Between createAdapter and joinAdapters we can create and reuse some really sophisticated state patterns.

Adapter Creators

What if we want to create an adapter for some properties, but allow for consumers of our adapter to define extra properties on the state interface? What we need is a function like this:

export function createOptionAdapter<T extends Option>() {
  return joinAdapters<T, Exclude<keyof T, keyof Option>>()({
    value: baseAdapter,
    checked: booleanAdapter,
  })();
}
Enter fullscreen mode Exit fullscreen mode

This will create the same adapter as above, but it will also allow additional properties on the state object. So if you had an Option interface, it could have all the properties you wanted, as long as it also had value and checked. This is similar to how state shape can be extended with NgRx/Entity and Redux Toolkit.

So, somebody could take the adapter returned from your function and combine it with their own adapter:

interface Person {
  loading: boolean;
  value: string
  checked: boolean;
}

const optionAdapter = createOptionAdapter<Person>();

export const personAdapter = createAdapter<Person>()({
  receivePerson: (state, person: Person) => ({
    ...state,
    ...person,
    loading: false,
  }),
  ...optionAdapter,
  selectors: {
    loading: state => state.loading,
    ...optionAdapter.selectors,
  },
});
Enter fullscreen mode Exit fullscreen mode

An OP entity adapter

Most state management libraries that provide utilities for managing lists of entities give you all the basic tools, like addOne, removeOne, setAll, removeMany, etc...

But what if we assume that developers are going to be creating state adapters for the entities in the entity state? And what if they pass that adapter into our createEntityAdapter function? What could we do with that?

Normally an entity adapter will have state change functions called updateOne and updateMany. The entity adapter itself has no idea what update is being passed to it—just that it needs to spread it into the entity object. Here is an example I found:

      return optionEntityAdapter.updateMany(
        state.ids.map((id: string) => ({
          id,
          changes: { selected: true },
        })),
        state,
      );
Enter fullscreen mode Exit fullscreen mode

All of this work just to flip selected to true in every entity.

If we use the booleanAdapter to define an optionAdapter, and in turn use the optionAdapter to define the entity adapter, we could know that setSelectedTrue is a state change on optionAdapter and automatically generate a state change function that can be used like this:

return optionEntityAdapter.setManySelectedTrue(state, state.ids);
Enter fullscreen mode Exit fullscreen mode

For that matter, why not just give them an All variation for each state change function:

return optionEntityAdapter.setAllSelectedTrue(state);
Enter fullscreen mode Exit fullscreen mode

Think of all the state change functions we could define in an individual entity's adapter, and then createEntityAdapter can handle all the annoying list stuff!

Filter selectors

What about filtering our entities? Well what if the optionAdapter had selectors that returned booleans? Couldn't we provide some entity selectors that used those to filter the entities?

Yes we can. I just used the same name as the filter, and it returns all entities for which selected is true.

What if we need to filter using multiple criteria? Just define it as another selector in the optionAdapter.

We can also use filter selectors to apply state changes selectively. Redux Toolkit and NgRx/Entity will have the One, Many and All ways of choosing which entities to change, but those are basically just filters. We can also create state change functions for each filter selector.

So let's say we wanted to select all options that start with the letter A. The optionAdapter first needs a filter selector for this:

startsWithA: option => option.value.startsWith('A')`,
Enter fullscreen mode Exit fullscreen mode

Now that becomes available on the entityAdapter like this:

entityAdapter.setStartsWithASelectedTrue
Enter fullscreen mode Exit fullscreen mode

Sorter selectors

If you define a selector that returns a value that can be compared with >, then it can also be used to sort the entities in a selector. The optionAdapter has a selector called value that returns a string, so we can use it to sort the entities in a selector called entityAdapter.selectedByValue, or entityAdapter.allByValue.

But should one?

This is extremely powerful. Potentially over-powered, and not in a good way. You might all be feeling like Jeff Goldblum right now.

Jeff Goldblum Preoccupied Quote

We already had a dozen or so state change functions in the optionAdapter, and that balloons to a ridiculous number when we feed it into createEntityAdapter. So, I added an options object that forces developers to specify which selectors to generate additional filter selectors and sort selectors for:

const optionEntityAdapter = createEntityAdapter<Option>(optionAdapter, {
  filters: ['selected'],
  sorters: ['value'],
  useCache: true, // Selectors for each entity can be memoized if expensive
});
Enter fullscreen mode Exit fullscreen mode

Even with this, optionEntityAdapter has around 100 state change functions to choose from. Honestly this is awesome, because TypeScript will filter the suggestions as you type and tell you what each does as you need it, none of which you needed to write. But what is the computational cost of this? Surprisingly, in my tests it only adds around a millisecond or two of computation time compared to creating a traditional entity adapter. This was something like 50% more time. So it's not going to slow the app down, but if it ever does, I can also look into making adapters proxy objects and defining these state change functions lazily.

TypeScript is also doing a lot of work, but so far the type-checking is still very fast. I fine-tuned it a bit, but I'm not a TypeScript mastermind, so I'm sure it could still be optimized.

But even with performance not being an issue, some of these names are just... awkward, aren't they? Any weird or ambiguous name in the individual entity adapter will get amplified in the entities adapter.

But all of this saves so much code, and could really speed up development. So, with all the code that this saves, maybe it is worth it.

Please feel free to try it out and give feedback!

Conclusion

I'm really sick of writing code that looks like this:

({
  ...
  {
    ...
    {
      ...
    }
  }
})
Enter fullscreen mode Exit fullscreen mode

or even dot-chaining with imperative code. Especially when it's the same exact code I've written dozens of times before!

I don't expect state adapters to completely solve all of these issues. But UI components didn't solve all our issues either, did they? But they made UI development much simpler in the end, because you don't even have to think about whether the UI code you're writing needs to be reused, because it's already in a component by default! Why not write our state management code in the same way? And just like how component libraries were created, maybe state adapter libraries could be created too, or maybe shared alongside component libraries. Think of how much component libraries have sped up your work; maybe state adapters could do the same.

I don't know if state adapters are the ultimate solution to state management, but I am certain that they are better than coupling state management logic with reducers, and the syntax is much nicer. If you're hesitant about any of the composability stuff I talked about in this article, maybe you could just try using the simple createAdapter function and see how that goes.

Also, if you want a really nice experience managing state with state adapters, I would recommend you look into using the other StateAdapt libraries. I have an RxJS library that hooks into Redux Devtools; you can see me using it in SolidJS and Svelte in this video (look at the timestamps). There's also an Angular library built on top of RxJS. And there's a React library too, although it could use some refining.

StateAdapt 1.0 hasn't been released yet, so be aware that this isn't production ready. However, it's very close. After I've applied the entity adapter to a few projects, I will be releasing 1.0.

Thanks for reading! Let me know what you think.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .