Valtio: A great state management solution for React
Valtio is a fantastic and relatively new state management library for React. It is extraordinarily clear, concise and easy to use, yet through the magic of proxies also delivers performance enabling selective reactivity (only the relevant parts of the view get updated on state changes).
This tutorial starts with something very simple, then shows how Valtio can scale to allow for much more complex cases. Valtio is a great example of a tool which can provide both an enjoyable journey, as we iterate, and a high quality destination. It is remarkably agile; one subtle downside of many state management approaches is how they are quite hard to make changes to, yet change is of course extremely common.
Setup
We start with a basic Code Sandbox (but similar steps apply for all modern setups) and add Valtio. We are using version 1.2.1
here. We import the basics and set up a store with the proxy
function. There is no ceremony or boilerplate here.
import { proxy, useSnapshot } from "valtio"
const store = proxy({
count: 0,
})
Now we want to use this in a component. That is also very simple:
const { count } = useSnapshot(store);
return (
<div className="App">
<h1>Hello Valtio {count}</h1>
We can add these hooks to any React components. There is no need to worry about setting up some Provider
.
Now this component will be updated on any change to the count
part of the store. To update the store we actually just modify the store
directly.
<button
onClick={() => {
store.count++
}}
>
Increment
</button>
On any such change all components which, via useSnapshot
, are observing this part of the store will be updated. For the trivial example here a useState
would have been fine, except it would be local. With Valtio we could use the state in multiple, perhaps very distant, parts of a React App.
Selective Reactivity
Let's see how this approach enables selective re-rendering. Let's create a little helper hook to track each render.
function useLogRender(name: string) {
const renderCount = useRef(0)
useEffect(() => {
renderCount.current++
console.log(`✨ Rendered ${name} ${renderCount.current}`)
})
}
And let's split up our 'App' into components:
function Counter() {
useLogRender("Counter")
const { count } = useSnapshot(store)
return <h1>Hello Valtio {count}</h1>
}
and
function Controls() {
useLogRender("Controls")
return (
<>
<button
onClick={() => {
store.count++
}}
>
Increment
</button>
</>
)
}
adding in use of the new hook. An aside, that was remarkably easy. No tedious rewiring required. Journey and destination.
If we look in the console and click the button a few times we see that only the Counter
component is getting re-rendered on changes. Okay but you might still be skeptical. Maybe any change to state will re-render components that useSnapshot
. As a simple demonstration this isn't the case, let's add something else to our store:
const store = proxy({
count: 0,
name: "James",
})
and use in a component
function UserForm() {
useLogRender("Form")
const { name } = useSnapshot(store)
return (
<input
type="text"
value={name}
onChange={(evt) => {
store.name = evt.target.value
}}
/>
)
}
and put into our App (I added a few styles to tidy it up):
<div
className="App"
style={{ display: "flex", flexDirection: "column", gap: 12 }}
>
<Counter />
<UserForm />
<Controls />
</div>
Now if you update the name and click on the button you'll see in both cases only the relevant part of the App gets re-rendered, yet you did absolutely nothing manually to make that happen, Valtio handled this selective reactivity.
Scaling
Okay, you might think, this Valtio thing looks kind of nice for simple stuff. That proxy
and useSnapshot
seem magical, but surely I can't do real 'enterprise' apps with this? On the contrary. Not only can this scale fine, it will actually do a better job than many popular approaches and will be much more able to handle changing requirements over time.
Let's make a To Do List App. This is a bit of a cliche, but that is for a reason. It is a sufficiently complex example to illustrate a number of things. Plus you've probably done something similar before so will likely have a clear picture to compare to.
In our earlier example you might have been concerned about a lack of encapsulation and (depending on your opinions) a mixing of view and business logic such as the store.count++
. In Valtio we can start this way, then easily extract code into decoupled, testable modules. Let's show how this would work with very basic To Do list.
Let's create a new file store.ts
and set up our Valtio state there. Notice how we no longer export the store. I've also set up a TypeScript type for to do items. I'm not going to focus on TypeScript here, but it is worth mentioning that Valtio works great with TypeScript.
import { proxy, useSnapshot } from "valtio"
type ToDo = {
text: string
done: boolean
id: string
}
const store = proxy<{
todos: ToDo[]
}>({
todos: [],
})
We also wrap the Valtio hook setup for by components.
export function useStore() {
return useSnapshot(store)
}
Let's actually use this. First create a ToDoList
component, using our custom hook.
function ToDoList() {
const { todos } = useStore()
return (
<>
<h1>Todos</h1>
{todos.map((t) => (
<div key={t.id}>
<p>{t.text}</p>
</div>
))}
</>
)
}
Let's update the controls with something to enter a new to do. Notice how we just useState
here. We could use Valtio, but a local piece of state seems fine too.
function Controls() {
const [pending, setPending] = useState("")
return (
<>
<input
type="text"
value={pending}
onChange={(evt) => setPending(evt.target.value)}
/>
<button
onClick={() => {
// ????
}}
>
Add
</button>
</>
)
}
And let's put that together in our App
. As before no ceremony required.
export default function App() {
return (
<div
className="App"
style={{ display: "flex", flexDirection: "column", gap: 12 }}
>
<ToDoList />
<Controls />
</div>
)
}
We'd better be able to actually add todos. Let's add a new function in store.ts
to handle this:
import { v4 as uuid } from "uuid"
// ...
export function addToDo(text: string) {
store.todos.push({
text,
done: false,
id: uuid(),
})
}
and wire it up:
<button
onClick={() => {
addToDo(pending)
setPending("")
}}
>
Add
</button>
If you try adding some todos you should see them added to the list. Again that was really simple and now we see how state changes can be very simply encapsulated. This would also be very easy to write tests for, indeed Valtio allows for easy access to and subscription to state outside of React, so makes this even easier than you might expect.
Taking things a bit further (no new concepts) we can easily add functionality for completing and deleting:
export function removeToDo(id: string) {
store.todos = store.todos.filter((t) => t.id !== id)
}
export function completeToDo(id: string) {
store.todos = store.todos.map((t) => {
if (t.id === id) {
return { ...t, done: true }
} else {
return t
}
})
}
Notice how decoupled this new functionality is from existing functionality. And how few places we add code to. Indeed it would be very easy to delete, one of the best heuristics for assessing code quality.
Asynchronous Code
Okay that looks promising, but all the effort I put into my Redux Sagas must be for something right? How does Valtio cope with harder stuff? Well even here you may find that things are considerably easier.
Let's start with a simple fake API (I moved the ToDo
type to its own file):
import { v4 as uuid } from "uuid"
import type { ToDo } from "./types"
export function getToDos(): Promise<ToDo[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{
text: "Convert to TypeScript",
done: Math.random() < 0.3,
id: uuid(),
},
])
}, 250)
})
}
and think about how we might wire this up. Normally when we do asynchronous stuff with React components we have to start to worry a bit about lifecycles (e.g. will my component even exist when I get a result). Valtio's state exists outside of React, which means this is basically not an issue. And if we do want cancellation, debouncing and various other things they are actually quite simple to add.
Let's add a button which pretends to fetch the latest to dos from the server (calls our 'api'). First let's add an update request action to the store module.
export async function updateToDos() {
const newTodos = await getToDos()
store.todos = [...store.todos, ...newTodos]
}
Yes that is all you need to write. Now let's call it via a button in our UI.
<button
onClick={() => {
updateToDos()
}}
>
Update
</button>
Okay so that basically the least code conceivable to do this kind of thing. Which makes it easy to change when requirements change. And easy to understand. And test. If we wanted to do some kind tracking of loading state that would also be very simple. We could also easily prevent a second request while another was loading.
Let's try something harder. What if I want to poll for updates to the list, but only while particular components are mounted. How could I do that? Again this is surprisingly easy. As we want to align with React's lifecycle we create a hook.
Let's use setInterval
and request the latest to dos (via our existing async function). In the callback returned by the useEffect
argument we clear the interval.
export function usePollForTodos() {
useEffect(() => {
const iv = setInterval(async () => {
await updateToDos()
}, 2000)
return () => {
clearInterval(iv)
}
}, [])
}
The async
/await
in the setInterval
callback aren't really necessary but make the intention clearer (indeed you could just use updateToDos
as the setInterval callback). And in a real use case we'd probably want a single setInterval
driving the polling (where using multiple usePollForTodos
), but that would be pretty easy too.
How do we add this to a component?
function ToDoList() {
const { todos } = useStore();
usePollForTodos();
Now while the To Do list is mounted, we will poll for updates.
Further
There were a bunch of things I didn't cover here that you can read about in the docs. In particular we didn't look at how subscribing to the state from outside of React allows lots of use cases which other libraries might struggle with. Valtio also works with Suspense, by throwing promises you access in the snapshot.
While there are use cases where a more specialised library may make more sense (especially api orientated things like React Query) I'd suggest Valtio as a great default state management solution for a wide variety of React, React Three Fiber and React Native Apps. It is simple for simple things, yet can easily grow. We can add more 'stores'. With the selective reactivity performance can be maintained without us having to explicitly memoise things.