Skip to main content

Command Palette

Search for a command to run...

Local State Management w/ Quiddity

Updated
5 min read
Local State Management w/ Quiddity

This post explores an alternative to reducers in React to manage non-trivial local state though the use of Quiddiny: a local first state management package.

Choosing Local State Intentionally

In React, there are many state management libraries available for global state. However, it is surprisingly difficult to find a good solution for managing local state. You might ask why one would want, or even prefer, local state over global state. The answer is fairly simple: global state can become messy and may introduce unintended side effects, since many components can end up coupled to the same shared state.

With local state, changes are isolated to the component where the state is used. This isolation improves testability and makes components easier to reason about and debug.

For simple cases, the useState hook works well enough, and basic derived state can be handled with useMemo. However, as local state grows in size or complexity, useState becomes less appealing. You end up tracking multiple state values along with how and where each one is updated. React does provide a built-in solution for this in the form of useReducer, which handles more complex local state well. That said, useReducer requires some upfront boilerplate, and its action-based approach can feel clunky in practice.

Scaling Local State with useState

Lets take the simple example using a counter to represent the use of local state while exploring how useState scales over time.

The useState hook works well for simple local state and keeps components easy to reason about. This example starts small, but it provides a useful foundation for seeing how local state can become harder to manage as additional behavior and derived values are introduced.

import { useState, useMemo, useRef } from 'react'

const App = () => {
    const [count, setCount] = useState(0)
    const doubledCount = useMemo(() => count * 2, [count])

    const increase = () =>
        setCount(count => count + 1)

    const multiplyBy = (x: number) =>
        setCount(count => count * x)

    const multiplyRef = useRef<HTMLInputElement>(null)

    return (
        <div>
            <div>Count is: {count}</div>
            <div>Doubled count is: {doubledCount}</div>

            <button onClick={increase}>Increase count</button>

            <div>
                multiply by:
                <input ref={multiplyRef} type="number" />

                <button
                    onClick={() =>
                        multiplyRef.current &&
                        multiplyBy(Number(multiplyRef.current.value))
                    }
                >
                    Apply Multiplication
                </button>
            </div>
        </div>
    )
}

The above example is a simple one that represents a single state of count which has a derived state of doubledCount and two actions increase and multiplyBy. Even adding one more state variable we increase the complexity and thus the state, memos, and callbacks grow. One solution is to address this with a reducer.

Centralization With Reducers

import { useMemo, useReducer, useRef } from "react"

type State = {
    count: number
}

type Action =
    | { type: "increase" }
    | { type: "multiply"; factor: number }

const initialState: State = {
    count: 0
}

const reducer = (state: State, action: Action): State => {
    switch (action.type) {
        case "increase":
            return { count: state.count + 1 }

        case "multiply":
            return { count: state.count * action.factor }

        default:
            return state
    }
}

const App = () => {
    const [state, dispatch] = useReducer(reducer, initialState)
    const doubledCount = useMemo(() => state.count * 2, [state.count])
    const multiplyRef = useRef<HTMLInputElement>(null)

    const increase = () => {
        dispatch({ type: "increase" })
    }

    const multiplyBy = (factor: number) => {
        dispatch({ type: "multiply", factor })
    }

    return (
        <div>
            <div>Count is: {state.count}</div>
            <div>Doubled count is: {doubledCount}</div>

            <button onClick={increase}>
                Increase count
            </button>

            <div>
                multiply by:
                <input ref={multiplyRef} type="number" />

                <button
                    onClick={() =>
                        multiplyRef.current &&
                        multiplyBy(Number(multiplyRef.current.value))
                    }
                >
                    Apply Multiplication
                </button>
            </div>
        </div>
    )
}

The reducer here does not provide much value yet, because it requires a fair amount of setup. One pet peeve of mine is that reducer logic often ends up in a switch statement. It works, and it is a common pattern, but it has always felt a bit redundant to me.

Quiddity's Approach

Redux appears to be falling out of favor, with many developers gravitating toward libraries that offer simpler and more expressive state management APIs, such as Jotai and Zustand. Seeing these projects made me wonder why local state could not benefit from the same level of elegance. That line of thinking ultimately led to Quiddity a simple and lightweight local state management library designed specifically for React components.

Quiddity simplifies local state management by removing much of the ceremony around defining state and by providing helpers that improve TypeScript ergonomics. In this post we will focus on using Quiddity with TypeScript. When using JavaScript, the combine function is not required, since its primary purpose is to improve type inference.

import { useRef } from "react"
import { create, combine } from "@oneirosoft/quiddity"

const useCounter = create(
    combine({ count: 0 }, set => ({
        increment: () =>
            set(state => ({ count: state.count + 1 })),

        multiplyBy: (factor: number) =>
            set(state => ({ count: state.count * factor }))
    })),
    state => ({
        doubledCount: state.count * 2
    })
)

const App = () => {
    const { 
        count, 
        doubledCount, 
        increment, 
        multiplyBy 
    } = useCounter()

    const multiplyRef = useRef<HTMLInputElement>(null)

    return (
        <div>
            <div>Count is: {count}</div>
            <div>Doubled count is: {doubledCount}</div>

            <button onClick={increment}>
                Increase count
            </button>

            <div>
                multiply by:
                <input ref={multiplyRef} type="number" />

                <button
                    onClick={() =>
                        multiplyRef.current &&
                        multiplyBy(Number(multiplyRef.current.value))
                    }
                >
                    Apply Multiplication
                </button>
            </div>
        </div>
    )
}

The approach Quiddity takes strikes a balance between the simplicity of useState and the structure of useReducer. Like useState, it keeps state local and easy to reason about. Like useReducer, it centralizes state transitions and derived values in one place. The difference is that it does so without introducing action objects, switch statements, or a separate dispatch step.

Compared to useState, this pattern scales more naturally as state grows. Related state updates, derived values, and behaviors live together, rather than being spread across multiple hooks and callbacks. Compared to useReducer, it removes much of the boilerplate and ceremony, allowing state changes to be expressed directly as functions with clear intent.

The result is local state that remains explicit and predictable, while staying lightweight and approachable. For components with non-trivial state, this can make the code easier to read, easier to extend, and easier to reason about over time.