@holycow/state
v1.2.1-alpha.0
Published
> Hook-based state management library for [React](https://github.com/facebook/react) applications.
Downloads
70,067
Readme
Holy State
Hook-based state management library for React applications.
Quick intro
Holy moly, you are here! You're more than welcome!
So, it is all about state management handled by hooks. Think of it as a utility for creating hooks that can store a global state across the entire application. The coolest part is that it works without context providers, observables, selectors, or HOC connectors. No boilerplate code but hooks.
🦄 Main features
- The library is tree-shakeable with no external dependency. Gzip size: ~1.6kb.
- The state hooks can be used outside of the React tree.
- Greedy rendering. Only updated values trigger component rendering.
- Computed values with caching and hook nesting.
- Asynchronous actions.
- Subscription to the state changes.
- Event-driven architecture support.
- Friendly with functional programming.
- Strongly typed with TypeScript.
🚀 Getting started
npm install @holycow/state
import {createState} from '@holycow/state'
// 👇 your store is a hook!
const useUser = createState({
id: 1,
name: 'Homer Simpson',
address: {
house: 742,
street: 'Evergreen Terrace',
},
})
const UserName = () => {
const {name} = useUser() // 👈 value from the state
return <div>{name}</div>
}
const {id, name, address} = useUser
// any values 👆 from the hook can be used outside of components
🍃 State update
It is quite simple to modify state values with the built-in set
function.
const {set} = useUser()
set('name', 'Ovuvuevuevue') // key & value
set('id', prevId => prevId + 1) // key & function
set('address.street', 'rue de Beggen') // path & value, no spreading objects
// atomic updates: multiple values at the same time
set(state => ({
...state,
id: state.id + 1,
name: 'Ovuvuevuevue',
}))
// or object as updating input
set({
id: prevId => prevId + 1,
name: 'Ovuvuevuevue',
})
The set
function is not only overloaded but curried as well. We can apply parameters to it partially one by one:
const setId = set('id') // returns function which will update 'id'
setId(2) // actual updates with a value
setId(prevId => prevId + 1) // or using function
fetch('/api/users/1/address')
.then(res => res.json())
.then(set('address')) // equals 👉 .then(data => set('address', data))
🎬 Actions
An action is any piece of code that modifies the state and targets some specific task, unlike the set
function, which is more generic and used for a simple value update. It is a good place to move business logic like validation or network operations.
Action expects a curried function as a parameter. The first function provides the state — the second one handles the action payload.
import {createState, action} from '@holycow/state'
const useAuth = createState({
token: '',
error: '',
loading: false, // 👇 state 👇 payload
login: action(({set, loading}) => formData => {
if (loading) return
set('error', '') // 👈 the state can be updated directly from the action
set('loading', true)
fetch('/api/login', {method: 'POST', body: formData})
.then(res => res.json())
.then(set('token'))
.catch(set('error'))
.finally(() => set('loading', false))
}),
})
// ⚡ handler is created outside of the component, +1 performance
const handleSubmit = event => {
event.preventDefault()
useAuth.login(new FormData(event.target))
}
const Login = () => {
const {loading, error} = useAuth()
return (
<form onSubmit={handleSubmit}>
<input name='email' type='text'/>
<input name='password' type='password'/>
<p>{error}</p>
<button type='submit' disabled={loading}>
{loading ? 'Submitting' : 'Login'}
</button>
</form>
)
}
🧠 Smart rendering
Unlike other state management, the holy state library does not require memoized selectors or further optimization to avoid unnecessary rerenders. Instead, it comes with a state tracking feature out of the box. Only components with altered values get rerendered.
const Street = () => {
const {address} = useUser()
return <div>{address.street}</div> // Evergreen Terrace
}
// 💤 no render even the address object was updated, +1 performance
useUser.set('address.house', 10)
// 💤 no render, equal value applied, +1 performance
useUser.set('address.street', 'Evergreen Terrace')
// 🏃♂️ now it will be rendered
useUser.set('address.street', 'Spooner')
🧮 Computed values
A computed value is a value returned by a specified function. The function's input can be a state value or any other hook. To avoid unnecessary computations, the computed value is cached and recomputed only when the current dependency has changed. Conceptually, computed values are similar to spreadsheets' formulas or Redux memoized selectors.
import {createState, computed} from '@holycow/state'
const useUser = createState({
name: 'Peter',
birthDate: {
day: 8,
month: 12,
year: 1979,
},
// 🦥 lazy evaluation, function will be called when the value is used
age: computed(state =>
new Date().getFullYear() - state.birthDate.year
),
})
// usage
const UserAge = () => {
const {name, age} = useUser()
// here 'age' 👆 value is calculated and cached
return <div>{name} is {age} years old guy.</div>
}
const homerAge = userUser.age // 👈 value from the cache, +1 performance
In the example above, the computed value age will be recalculated if the year of birthDate is modified. Otherwise, it will use the cached value.
What if we want to keep our age
value updated when the year is changed? Let's assume our hard-working QA engineer opens our app on 31 December at 11:58!
We could use the useCurrentYear hook to keep the value updated, respectively. Then we should wrap the hook with the side effect function.
const useUser = createState({
name: 'Peter',
birthDate: {
day: 8,
month: 12,
year: 1979,
}, // side effect function 👇
age: computed((state, sideEffect) =>
sideEffect(useCurrentYear) - state.birthDate.year
// when it's required to pass a parameter 👇
// sideEffect(() => useCurrentYear('some-params'))
),
})
We should remember by using side effects, we lose the benefit of caching.
🤹 Selectors
Selectors are designed for convenient state access. The selector retrieves a value from the state at a given path. If we query more than one value, the selector will return an array with the requested values.
const useMessages = createState({
author: {
id: 1,
name: 'Peter',
},
messages: [
{id: 10, text: 'Hello'},
{id: 20, text: 'World!'},
]
})
// single value
const authorName = useMessages('author.name')
// 👆 equivalent 👇
const {author} = useMessages()
const authorName = author.name
// multiple values
const [authorName, firstMessage] = useMessages('author.name', 'messages.0.text')
// 👆 equivalent 👇
const {author, messages} = useMessages()
const authorName = author.name
const firstMessage = messages[0].text
// one line component with a selector 👇
const AuthorName = () => <div>{useMessages('author.name')}</div>
The same trick we can do with actions or set
functions:
const setUser = useUser('set')
const setMessage = useMessages('set')
// 👆 equivalent 👇
const {set: setUser} = useUser()
const {set: setMessage} = useMessages()
🗃️ Context state
Context state is a multiple-instance state with its own scope. It is designed to create reusable nested components that share one state with their child components. It is similar to the React Context, but powered by holy state features.
- Greedy rendering, meaning that only updated values trigger component rendering.
- Computed values with caching and hook nesting.
- Asynchronous actions.
However, the state hook can only be used inside React components and does not support subscription to the state changes.
The createContextState
function creates a state and returns a tuple with a context provider and a hook. The initial context state can be overridden by a parent component. This allows you to create uncontrolled, controlled, or partially controlled components.
import {action, createContextState} from '@holycow/state'
const [Context, useCounter] = createContextState({
// 👇 the context initial state
count: 0,
name: `Untitled`,
increment: action(({set}) => () =>
set(`count`, value => value + 1)
),
})
const Label = () => {
// same as the holy state hook 👇
const {name, count} = useCounter()
return <h1>{name}: {count}</h1>
}
const Button = () => {
const {increment} = useCounter()
return (
<button onClick={increment}>
Increment
</button>
)
}
// only assigned props {name, count, increment}
// will override the initial context state
// undefined props 👇 will be ignored
const Counter = props => (
<Context value={props}>
<Label/>
<Button/>
</Context>
)
const App = () => {
const [age, setAge] = useState(21)
return (
<main>
// each Counter component will have it is own state
<Counter name='🐑 counter'/>
<Counter count={age} increment={() => setAge(age + 1)}/>
// 👆 controlled component by parent component
</main>
)
}
📬 State subscriptions
We can subscribe to the state changes and get notified when the state is updated. The subscribe
function accepts a callback function as a parameter and returns another function for unsubscription.
// subscription to the whole state 👇
const unsubscribe = useUser.subscribe(state => {
localStorage.setItem('user', JSON.stringify(state))
})
unsubscribe() // canceling subscription 👆
// subscription to specific 👇 value
useUser.subscribe('address.street', street => {
console.log(`User street was changed to ${street}`)
})
📢 Signal Events
Signals provide a simple way to communicate between decoupled hooks that don't know each other directly, but some of them wait for the other to occur to do something. So, for example, we could import a user profile state in lazy mode when the user gets logged in. But before that, we fetch only the required hooks to handle the guest state. On the other hand, it avoids tight coupling of hooks and can resolve circle dependencies issues. Shortly, the signals are the implementation of event-driven architecture.
It is optional to use the signals. Subscriptions and nesting hooks can provide the same functionality.
There are three steps to use the signals:
- Creation of the emitter function:
const ringDoorbell = createSignal()
. - Creation of the signal listener:
on(ringDoorbell, useDoor.open)
. - Call
ringDoorbell()
function to trigger the action.
import {createSignal, on} from '@holycow/state'
const logout = createSignal() // 👈 creates logout signal function
// auth.js
on(logout, () => { // 👈 listens to the logout signal
useAuth.logout()
console.log('Bye bye!')
})
// user.js
on(logout, () => {
useUser.reset() // built-in function that restore initial state of the hook
localStorage.removeItem('user')
})
// 👇 emits the logout signal
logout()
To disable the listener, we should call the function returned by the on
function.
const off = on(login, useAuth.login)
login('[email protected]', 'pa$$word')
off() // 👈 stops to react on the login signal
Signals can be executed once and then removed from the listeners.
import {createSignal, once} from '@holycow/state'
const init = createSignal()
// 👇 instead of 'on' we use 'once'
once(init, () => {
usePosts.loadPosts()
})
init() // 👈 will trigger the callback function
init() // no effects
📎 TypeScript
The state typing is designed to be seamless. Once it is typed, it should provide the correct types everywhere.
import type {Action, Computed, Computed} from '@holycow/state'
import {createState} from '@holycow/state'
type Todo = {
id: number
checked: boolean
description: string
}
/**
* Computed<StateType, ReturnType>
* Action<StateType> action with no payload
* Action<StateType, [PayloadType1, PayloadType2 ...PayloadTypeN]>
*/
type TodosState = {
filter: 'all' | 'completed' | 'uncompleted'
todos: Todo[]
filteredTodos: Computed<TodosState, Todo[]> // 👈 computing function returns Todo[]
addTodo: Action<TodosState, [string, boolean | undefined]> // 👈 action with payloads
clearTodos: Action<TodosState> // 👈 action without payload
}
// 👆 TypeScript zone, it can even be in a separate file.
const useTodos = createState<TodosState>({
// 👇 below like a normal JS code
filter: 'all',
todos: [
{id: 1, checked: true, description: 'Buy milk'},
{id: 2, checked: false, description: 'Clean room'},
],
filteredTodos: computed(state => {
const {filter, todos} = state
const isAll = filter === 'all'
const isCompleted = filter === 'completed'
return isAll ? todos : todos.filter(
({checked}) => checked === isCompleted
)
}),
addTodo: action(state => (description, checked = false) => {
const {set, todos} = state
const id = todos.reduce((acc, {id}) => Math.max(id + 1, acc), 0)
const newTodo = {id, description, checked}
set('todos', [...todos, newTodo])
}),
clearTodos: action(({set}) => () => {
set('todos', [])
})
})
const [addTodo, set, todo] = useTodos('addTodo', 'set', 'todos.0') // ✅ all good
const [addTodo, set, todo] = useTodos('addtodo', 'set', 'todos.0') // ❌ type error
// 👆 typos
todo?.description // ✅ all good
todo.description // ❌ type error, it might be undefined
addTodo('Buy milk') // ✅ all good
addTodo(123) // ❌ type error
set('filter', 'completed') // ✅ all good
set('filter', 'new') // ❌ type error
const setFilter = set('filter') // curried function
setFilter('completed') // ✅ all good
setFilter('new') // ❌ type error