A cheezy-simple 679 bytes
hook based immutable store
, that leverages useState
and Immer
to create independent rendering trees so that your components only re-render when they should.
Motivation
I have been struggling to find a state management solution for react
that makes you interact with your state using plain functions as a baseline. Most of the alternatives I found compromise simplicity, they're verbose or super abstract. I wanted an option that didn't force me to adopt a specific data pattern and was lean.
I don't like boilerplate code. It's the main reason why I stopped using redux
, but I never stopped chasing most of its design goals. I love how in redux
, components can be built in isolation, tested easily, and its overall separation of concerns.
While using some of the available redux
alternatives, I kept asking myself:
- "Where is the
connect
function?".
- "How do I attach the state to my component without rewriting it?".
This led to many awkward implementations attempts, that ultimately fell short one way or another.
I also love TypeScript, and it has been hard to find a well balanced solution that satisfied all my requirements as well as having strong type support.
Last but not least: your state management should be easy to understand for someone that didn't participate in the project design choices.
Design Goals
Install
$ yarn add --dev --exact mozzarella immer react-fast-compare
Basic Example (try it)
import React from 'react'
import ReactDOM from 'react-dom'
import { createStore } from 'mozzarella'
const { getState, createAction, useDerivedState } = createStore({
names: ['kilian', 'arianna', 'antonia', 'pasquale'],
places: ['san francisco', 'gavardo', 'salò']
})
const addName = createAction((state, name: string) => {
state.names.push(name)
})
const addPlace = createAction((state, name: string) => {
state.places.push(name)
})
const Names = () => {
console.info('<Names /> re-render')
const names = useDerivedState(state => state.names)
return (
<div>
<button onClick={() => addName('prison mike')}>Add Prison Mike</button>
<button onClick={() => addPlace('scranton')}>Add Scranton</button>
<h2>Names:</h2>
<ul>
{names.map((name, key) => (
<li key={key}>{name}</li>
))}
</ul>
<h2>State:</h2>
<pre>{JSON.stringify(getState(), null, 2)}</pre>
</div>
)
}
ReactDOM.render(<Names />, document.getElementById('root'))
Example with pure functional components (try it)
import { createStore } from 'mozzarella'
export const { getState, createAction, useDerivedState } = createStore({
fruits: []
})
import { createAction } from './store'
export const addFruit = createAction((state, name: string) => {
state.fruits.push(name)
})
export const popFruit = createAction((state) => {
state.fruits.pop()
})
import React, { FC } from 'react'
import * as actions from './actions'
import { useDerivedState } from './store'
type FruitsProps = {
fruits: string[]
onRemove: () => void
onAdd: (name: string) => void
}
export const Fruits = ({ fruits, onRemove, onAdd }: FruitsProps) => (
<div>
<h2>Fruits:</h2>
<ul>
{fruits.map((fruit, key) => (
<li key={key}>{fruit}</li>
))}
</ul>
<button onClick={onRemove}>remove last fruit</button>
<button onClick={() => onAdd('bananas')}>add bananas</button>
</div>
)
Fruits.Connected = (() => {
const derivedProps = useDerivedState((state) => ({
fruits: state.fruits,
onAdd: actions.addFruit,
onRemove: actions.popFruit
}))
return <Fruits {...derivedProps} />
}) as FC
import React from 'react'
import ReactDOM from 'react-dom'
import { Fruit } from './fruit'
export const App = () => (
<Fruit.Connected />
)
ReactDOM.render(<App />, document.getElementById('app'))
API Reference
createStore
createStore <S>(initialState: S) => {
getState: () => S
useDerivedState: <R>(selector: (state: S) => R) => R
createAction: <U extends unknown[]>(actionFn: (state: Draft<S>, ...params: U) => void) => (...params: U) => void
}
Takes the initial state as parameter and returns an object with three properties:
Example
type State = {
users: Record<User>,
photos: Record<Photo>,
albums: Record<Album>,
likes?: Record<Likes>
}
const { getState, createAction, useDerivedState } = createStore<State>({
users: {},
photos: {},
albums: {}
})
getState
const getState = () => S
Returns the instance of your immutable state
Example
const { likes } = getState()
createAction
const createAction = <U extends unknown[]>(actionFn: (state: Draft<S>, ...params: U) => void): (...params: U) => void
Takes a function as input and returns a closured action function that can manipulate a Draft<S>
of your state.
Examples
API call
const login = createAction(async (state, email: string, password: string) => {
const {
err,
userId,
apiToken
} = await apiRequest('/auth', { email, password })
state.auth = {
err,
userId,
apiToken
}
})
<div>
{auth.err ? <h1>Error: {err.message}</h1> : null}
<button onClick={() => login('me@me.com', 'password')}>
login
</button>
</div>
Nested actions
const fetchUsers = createAction(async (state, amount: number) => {
const data = await apiRequest('https://url/data')
data.users.forEach((user) => {
state.users[user.id] = user
})
setPhotos(data.photos)
})
const setPhotos = (photos: Photo[]) => {
photos.forEach(setPhoto)
}
const setPhoto = createAction((state, photo: Photo) => {
state.photos[photo.id] = photo
})
Batching state changes
const changeName = createAction((state, name: string) => {
state.name = name
})
for (let i = 0; i < 100; i++) {
changeName(`name_${i}`)
}
useDerivedState
const useDerivedState: <R>(selector: (state: S) => R, dependencies?: DependencyList) => R
Hook that given a selector function, will return the output of the selector and re-render the component only when it changes.
As per usual, this hook takes an optional dependencies
parameter that defaults to
[]`.
Example
const UserProfile = (props: { user: User, photos: Photo[] }) => {
return (
<div>
<h1>User Profile: {props.user.username} ({props.photos.length} photos)</h1>
<div>
{props.photos.map((photo) => <Photo key={photo.id} photo={photo} />)}
</div>
</div>
)
}
UserProfile.connected = (props: { userId: string }) => {
const derivedProps = useDerivedState((state) => {
user: state.users[props.userId],
photos: Object.values(state.photos).filter((photo) => photo.userId === props.userId)
}, [props.userId])
return <UserProfile {...derivedProps} />
}
Or if you're not being dogmatic about it, or simply not implementing a strict design system:
const UserProfile = (props: { userId: string }) => {
const { user, photos } = useDerivedState((state) => {
user: state.users[props.userId],
photos: Object.values(state.photos).filter((photo) => photo.userId === props.userId)
}, [props.userId])
return (
<div>
<h1>User Profile: {user.username} ({photos.length} photos)</h1>
<div>
{photos.map((photo) => <Photo key={photo.id} photo={photo} />)}
</div>
</div>
)
}
How to contribute
Contributions and bug fixes from the community are welcome. You can run the test suite locally with:
$ yarn lint
$ yarn test
License
This software is released under the MIT license cited below.
Copyright (c) 2020 Kilian Ciuffolo, me@nailik.org. All Rights Reserved.
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the 'Software'), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.