How we think
Why state lives outside UI
When logic leaves the components, a framework becomes a simple rendering layer. We think much of the complexity we attribute to frameworks comes from the logic we write inside them.
This is the idea Nano Stores is built around, and it is our opinion, not an API rule: we think a component should draw state, not own it.
The problem
It is easy to write a feature inside a component, and we understand why. Loading, derived values and cleanup all fit next to the markup:
export function Header() {
const [user, setUser] =
useState<User | null>(null)
useEffect(() => {
fetchUser().then(setUser)
}, [])
const isAdmin = useMemo(
() => user?.roles.includes('admin'),
[user],
)
return (
<span>
{isAdmin ? 'Admin' : 'User'}
</span>
)
}It works, but we don't like where the model of the feature ends up: in the render cycle. Where the data comes from, when it loads and what depends on what are written as effects and dependency arrays, in the terms of one framework.
What we think it costs
- Testing. To check that the admin flag is derived correctly, the test has to render the component in a framework's test environment and emulate its lifecycle.
- Reuse. The next screen that needs
isAdmineither copies the effect or lifts the state into a provider. In both cases the component tree comes along. - Moving. Another framework, or a second client such as React Native, means rewriting the logic, because it is written in the framework's terms.
The other way
We'd rather have the store own the state, the computations and the lifecycle. The component then renders the ready value, and the link to the framework is one line at the boundary:
export const $user = atom<{ roles: string[] } | null>(null)
onMount($user, () => {
task(async () => {
$user.set(await fetchUser())
})
})
export const $isAdmin = computed($user, (user) => user?.roles.includes('admin'))export function Header() {
const isAdmin = useStore($isAdmin)
return <span>{isAdmin ? 'Admin' : 'User'}</span>
}onMount starts loading when something subscribes, task marks the loading so that tests and server rendering can wait for it, and computed derives the flag (see Smart stores). useStore is the only React-specific line. Vue, Svelte and the other frameworks have their own one-line equivalent.
Testing a store
To test the admin flag in the component version, the test renders the component in a DOM environment and waits for the rendered output:
it('shows Admin after the user loads', async () => {
vi.stubGlobal('fetchUser', async () => ({ roles: ['admin'] }))
render(<Header />)
expect(await screen.findByText('Admin')).toBeTruthy()
})The store is plain JavaScript, so its test needs no renderer and no DOM, which is a large part of why we prefer this shape. keepMount keeps the store active during the test, allTasks waits for the loading started by task, and cleanStores resets the store afterwards (see Tests):
afterEach(() => {
cleanStores($user, $isAdmin)
})
it('loads the user and derives admin status', async () => {
keepMount($isAdmin)
await allTasks()
expect($isAdmin.get()).toBe(true)
})UI as a skin
We like to think of a component as a skin over the state. It decides how things look, and the store decides what is true. Swap the skin, whether it is another framework, another platform or a test, and the state and its logic stay the same.
The shape is a familiar one. If it sounds like MVC, it is: stores are the model, components are the view.
What it costs us
- The logic is not next to the markup. Reading a feature means opening the store and the component.
- State that belongs to one component and dies with it doesn't need a store, and we'd keep a local
useStatethere.