Skip to content
TkDodo's blog
BlueskyGithub

Introducing XState Store

JavaScript, TypeScript, State Management, XState4 min read

Open vintage sign broad through the glass of store window
  • 简体中文
  • Add translation

I'm super happy with the tech stack I'm currently using, especially around state management. Obviously, server state is managed by React Query. For forms, I use React Hook Form.

What remains can very often be put into the url, which is really a joy with TanStack Router. If that's not a good fit, I use Zustand - my favourite client state manager to date.

This has been my recommended stack for quite some time (okay, the router is quite new, but the concept isn't), and I'm not known for easily switching state managers. Every once in a while, something new comes out, but in order for me to switch, it has to be quite a lot better than what I currently work with.

But today might be that time.

xstate/store

When I first read about xstate/store, I was immediately intrigued by a couple of things. For one, it was made by David Khourshid, and whatever he builds usually overlaps conceptually with my thinking. And second, it felt like he totally nailed the API on xstate/store. On the first glance, it looked like zustand and redux-toolkit had a child, combining the best of both libs.

Let's take a look at an example, and for easy comparison, I'll be using a similar example as the one from my working with zustand article:

example
1import { createStore } from '@xstate/store'
2import { useSelector } from '@xstate/store/react'
3
4const store = createStore({
5 // context
6 context: {
7 bears: 0,
8 fish: 0,
9 },
10 // transitions
11 on: {
12 increasePopulation: {
13 bears: (context, event: { by: number }) => context.bears + event.by,
14 },
15 eatFish: {
16 fish: (context) => context.fish - 1,
17 },
18 removeAllBears: {
19 bears: 0,
20 },
21 },
22})
23
24export const useBears = () =>
25 useSelector(store, (state) => state.context.bears)
26export const useFish = () =>
27 useSelector(store, (state) => state.context.fish)

createStore is the main function we need to use from xstate/store, which is split into two parts: context and transitions. Conceptually, context is the state of our store, while transitions are similar to actions.

One could say that this is only marginally different to zustand, so what's intriguing about this? Well, to me, there are quite many things. Let's break it down:

TypeScript

It will infer TypeScript types of the store from the initial context. This is pretty great and something that was usually a lot more verbose with zustand (There are some ways to make this better with the combine middleware).

Note that the above example is already in TypeScript, and the only thing we needed to manually type was the event passed into our increasePopulation transition. That's really how user-land TypeScript should be: The more it looks like plain JavaScript, the better.

Transitions

The store has a natural split between state and actions, which is something that I recommend doing with zustand as well. Except that in xstate/store, transitions aren't part of the store state, so we don't have to select them to perform updates / exclude them when persisting the store somewhere etc.

Event driven

Speaking of updates: if we don't select actions from the store - how do we trigger a transition? Quite simply with store.send:

transitions
1function App() {
2 const bears = useBears()
3
4 return (
5 <div>
6 Bears: {bears}
7 <button
8 onClick={() =>
9 store.send({ type: 'increasePopulation', by: 10 })
10 }
11 >
12 Increment
13 </button>
14 </div>
15 )
16}

It wouldn't be an xstate like library if the store itself wasn't event driven. Again, this is something I've also recommended for doing with zustand, because events are a lot more descriptive than setters and they make sure that the logic lives in the store, not in the UI that triggers the update.

So with store.send, we are triggering a transition from one state to the next. It takes an object with type, which is derived from the keys of the transition object we've defined on our store. And of course, it's totally type-safe.

This is where I've seen some similarities with redux toolkit, and dispatching events has always been my favourite part of the redux design.

Selectors

Yes, zustand is built on selectors as well, but notice how the created store isn't a hook itself - we have to pass it to useSelector, which requires us to pass a selector function, too. That means we are less likely to subscribe to the complete store by accident, which is a common performance pitfall with zustand. Additionally, we can also pass a comparison function as 3rd argument to useSelector in case the default referential comparison isn't good enough.

Framework agnostic

Maybe you've seen it - createStore is imported from @xstate/store while useSelector is imported from @xstate/store/react. That's because the store itself knows nothing about React, and the React adapter is literally just a wrapper around store.subscribe put into useSyncExternalStore.

If this sounds familiar, maybe that's because TanStack Query has the same approach, so maybe we'll see different framework adapters for xstate/store in the future, too.

Upgrade to state machines

State machines have the reputation of being a complex tool to adopt, which is why a lot of people shy away from them. And I think it's true that they are likely "overkill" for most state that gets managed in web applications.

However, state usually evolves over time, getting more complex as requirements are added. I've seen lots of code in useReducer or an external zustand store where I thought: This should obviously be a state machine - why isn't this one?

The answer is usually that by the time we realize that it should be a state machine, it's already so complex that creating one out of it is not an easy thing to do anymore.

And that's again where xstate/store shines because it offers a simple upgrade path to convert a store into a state machine. It might not be something you think you need, but it's exactly the thing that you're happy you have available for free if you need it.


When my article working with zustand came out, it was very well received because it provides some opinionated guidance for working with a tool that mostly stays out of your way. It lets you structure and update your store the way you want to - total freedom that can also be a bit paralyzing.

xstate/store feels to me like a more opinionated way of achieving the same thing. And the fact that the opinions overlap a lot (like really a lot) with how I would do things make it a very good choice for me.


That's it for today. Feel free to reach out to me on bluesky if you have any questions, or just leave a comment below. ⬇️