Skip to content

sultan99/holycow

Repository files navigation

Holy State

codecov npm bundle size NPM Downloads GitHub License

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.9kb.
  • 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('homer@simpson.com', '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