Meet Storeon, a tiny state manager for modern frontend applications close in spirit to Redux, implemented in a single file that boils down to 173 bytes of minified and gzipped JavaScript.
Storeon is ready to be used in React projects or, if you care about bundle size as much as I do, with my favorite 3 KB React alternative, Preact. In both cases, the whole Storeon library, including the code to connect it to your components will never increase your frontend bundle by more than 300 bytes. Also, size is not the only feature of Storeon.
- It is fast. It tracks which parts of the application’s state were changed and re-renders only components affected by those changes.
- It supports React’s and Preact’s hooks.
- It is fully compatible with Redux DevTools.
- It has a concise and straightforward API with documentation that fits on a napkin.
It also makes dealing with the project’s file structure much easier. No one prevents you from placing your initial state, reducers, and business logic in a single module. No more need to jump between multiple open files:
let counter = store => {
// Initial state
store.on('@init', () => ({ count: 0 }))
// Reducers returns only changed part of the state
store.on('inc', ({ count }) => ({ count: count + 1 }))
}
export const store = createStoreon([counter])
Then inside your components, you can cherry-pick the parts of the state you want to deal with:
export default const Counter = () => {
const { dispatch, count } = useStoreon('count')
return <button onClick={() => dispatch('inc')}>{count}</button>
}
Runtime performance
When we talk about a frontend application’s performance, we mean two things: how fast does it load (time to fetch all necessary resources and process them in the browser) and how fast does it respond to user’s actions. I believe the latter is more important, so we’ll talk about it first.
With Redux, you connect your global state store to multiple components with the connect()
decorator and a selector function that returns a subset of global state.
// Redux way
export default connect(store => {
// The selector function
return { users: store.users }
})(Counter)
Having to re-build Virtual DOM on frequent events (like keyup
when a user is typing) hurts your app’s responsiveness. To avoid losing performance, Redux constantly keeps track of selector functions’ returns and re-renders the component only if the values have changed. Still, on any change in global state, Redux needs to call selectors of every connected component in your app and compare their results with previous values. What about a different approach?
Storeon checks which parts of the state were changed and re-renders only components subscribed to those changes.
Take another look at a “counter” example from above:
export default const Counter = () => {
// Component will be re-rendered only if `state.count` changes
const { dispatch, count } = useStoreon('count')
return <button onClick={() => dispatch('inc')}>{count}</button>
}
Start-up performance
In the naive past, we thought that everything depends on file sizes and bandwidth. Then we learned that we should also care about latency and avoid unnecessary request chaining. Now, when web applications shove hundreds of kilobytes of JavaScript down to clients, we are running into another problem: turns out 10 kilobytes of JS are “heavier” than 10 kilobytes of image data.
On lower-end devices, parsing, compiling, and executing JavaScript takes more time than downloading it.
Redux, in combination with React Redux, adds 21.5 KB of minified JavaScript to your bundle. Storeon, in contrast, will never add more than a few hundred bytes to your app, and here is why:
"size-limit": [
{
"path": "index.js",
"import": "{ createStoreon }",
"limit": "173 B"
}
]
That is a snippet from the Storeon’s package.json file. This key ensures that the size of the library is kept in check with Size Limit, a tool that I created and wrote about earlier: it allowed to hunt down bundle bloat for dozens of popular frontend libraries, including MobX and Material-UI.
On every commit to Storeon, Size Limit creates an empty webpack project, adds the library to the bundle and tracks the resulting size. That is how we ensure that the main component of Storeon will never increase the real cost to end user by more than 173B and React/Preact hooks integration—by more than 331B on top of that.
Shaving 20KB off your bundle might not seem like a big deal, but it is a solid first step in the right direction.
Once you are mindful about bundle costs, savings start to add up.
Using Preact/Storeon combo instead of React/Redux could improve your “Time to Interactive” metric by few noticeable seconds.
API
A library’s API is always an extremely subjective topic. Here’s how Storeon compares to Redux when defining reducers:
// Redux
switch (action.type) {
case 'INCREMENT':
return state + 1
case 'DECREMENT':
return state - 1
default:
return state
}
// Storeon
store.on('INCREMENT', ({ count }) => ({ count: count + 1 }))
store.on('DECREMENT', ({ count }) => ({ count: count - 1 }))
Even though Redux’s implementation does not force it per se, common React/Redux methodologies impose a fine-grained file structure that many find hard to deal with.
Often the application store is split into independent collections, like so: { users: […], projects: […] }
. With Redux, its action types, action creators, reducers, and initial state, it is hard to keep all users
-related code in one file.
With Storeon, you can keep everything related to users in a single place and still keep your code neat:
// store/users.js
export default initialUsers => store => {
// Initial state
store.on('@init', { users: initialUsers })
// Reducers
store.on('users/save', (({ users }), user) => ({ users: users.concat([user]) }))
// Async event listeners ≈ redux thunk
store.on('users/add', async (state, user) => {
try {
await api.addUser(user)
store.dispatch('users/save', user)
} catch (e) {
store.dispatch('errors/server-error')
}
})
}
And then create a unified store like so:
// store/index.js
import { createStoreon } from 'storeon'
// The module for every key in the state
import users from './users.js'
import projects from './projects.js'
...
export default createStoreon([
users(window.initial.users),
projects(window.initial.projects),
...
])
Storeon does not require you to import action creators into your components. As a result, components are clean from logic and can be easily placed into UI kits for reuse.
export default const AddForm = () => {
const { dispatch } = useStoreon()
return <button onClick={() => dispatch('users/add', user)}>Add</button>
}
And, by the way, you can use Storeon with Redux DevTools!
export default createStoreon([
...,
process.env.NODE_ENV !== 'production' && require('storeon/devtools')
])
Trade-offs and alternatives
There is no silver bullet for a frontend state manager. There are always compromises. With Storeon, I had to make some sacrifices in the name of the size, performance, and maintainability.
Due to the way Storeon’s code is structured, it will be impossible to implement proper support for hot reloading, as Redux does by separating reloadable “pure” reducers and not-reloadable “impure” middleware. In Storeon, both reducers and middleware (or action creators) are implemented as event listeners in the same file.
Another trade-off: state in Storeon must always be represented by a JavaScript object ({}
) with no restrictions on data types for its keys. This assumption allows to track state changes directly and to avoid Redux-style selector functions altogether. In my experience, non-object state stores are rare anyway.
When talking about frontend state management, it will not be fair not to mention the alternatives:
- Redux is great. You don’t need to replace it if you are happy with it.
- MobX is fast. As Storeon, it doesn’t need to call selector functions on any state change.
- Effector is new. Might be your choice if you don’t want to keep the whole state in a single store.
Or take a look at at least 43 ways to manage state, Storeon included!
Storeon started as a fun exercise in minimalism but ended up being much more than a gimmick. I am happily using the Storeon/Preact combo in my side projects, and as I don’t urge anyone to replace Redux in big production code bases, it can be a natural choice for state management in your next greenfield frontend application.
Especially if you agree that size and performance still matter.
Changelog
2020-04-04
- Update Storeon API
- Use ES modules in Size Limit example