@shrugsy/use-immer-state
v3.1.2
Published
Use immutable state with inbuilt time travel. Super-charged use-state with immer capabilities.
Downloads
46
Maintainers
Readme
use-immer-state
A React hook that provides a supercharged version of the useState
hook. Allows for writing easy immutable updates. Heavily inspired by @reduxjs/redux-toolkit's usage of immer
.
Table of Contents
Installation
npm i @shrugsy/use-immer-state
Within your React app:
import { useImmerState } from "@shrugsy/use-immer-state";
Features
- Includes all functionality from
useState
. - When using the 'functional update' setter callback, updates can be written 'mutably', and the setter internally uses
immer
to produce the next immutable state. - Throws an error if a state mutation is detected between mutations to help fix bad habits (except in production mode).
- Provides inbuilt time-travel history including 'checkpoints', 'goTo', 'goBack', 'goForward' and 'reset' functionality.
- Full typescript support.
Note: If you're looking to be able to write 'mutable' draft updates for more complex state, I recommend either:
- Check out use-local-slice.
- Use
createReducer
from@reduxjs/toolkit
in combination with the inbuiltuseReducer
hook.
Usage
Basic Usage
At it's core, it can be used identically to the inbuilt useState
hook.
e.g.
import { useImmerState } from "@shrugsy/use-immer-state";
const [user, setUser] = useImmerState({ id: 1, name: "john smith" });
function handleUpdateUser(newName) {
// nothing special here, this is how you might do it with `useState` currently
setState({ ...user, name: "Jane Doe" });
}
When using a callback to perform functional updates, behaviour is as follows:
- New state is computed using the previous state (same as
useState
) - The updates within the callback can be written
mutably
, but internally produce the next immutable update, without mutating the state
e.g.
const [user, setUser] = useImmerState({ id: 1, name: "john smith" });
function handleUpdateUser(newName) {
// the functional update notation allows writing the update mutably, and will internally produce an immutable update without mutating the actual state
setState((prev) => {
prev.name = "Jane Doe";
});
}
The benefits shine more for nested updates that would be messy to write manually. e.g.
// given some initial state like so:
const initialState = [
{
todo: "Learn typescript",
done: true,
},
{
todo: "Try use-immer-state",
done: false,
},
{
todo: "Pat myself on the back",
done: false,
},
];
const [todos, setTodos] = useImmerState(initialState);
function handleToggleTodo(index, isDone) {
setTodos((prevTodos) => {
if (prevTodos[index]) prevTodos[index].done = isDone;
});
Note: To achieve a similar effect with plain useState
,
the update would look more like this:
const [todos, setTodos] = useState(initialState);
function handleToggleTodo(index, isDone) {
setTodos((prevTodos) => {
return prevTodos.map((todo, idx) => {
if (idx !== index) return todo;
return { ...todo, done: isDone };
});
});
}
Note that the deeper the nested updates become, the larger the advantage will be to use this notation.
Advanced Usage
The tuple returned by useImmerState
includes an optional third value; extraAPI
like so:
const [state, setState, extraAPI] = useImmerState(initialState);
Note that you can name the value anything you like, or de-structure the values out directly.
state
is the current state, similar to what you would receive fromuseState
.setState
is the function to use when updating state, similar to what you would receive fromuseState
.
Key differences:- When providing a callback updater, updates can be written
mutably
, and are appliedimmutable
behind the scenes - It accepts an optional second boolean argument to dictate whether that state update should contribute to the state 'history' object (defaults true).
- When providing a callback updater, updates can be written
extraAPI
is an object that contains the following values:
Note: For the purposes of the table below,
S
refers to the type ofinitialState
.
| Name | Type | Description |
| ----------------- | ---------------------- | -------------------------------------------------------------------------------------- |
| history | ReadOnlyArray<S> | (default [initialState])
An array of the state history |
| stepNum | number | (default 0)
The current step (index) within the state history |
| isFirstStep | boolean | Whether the current step is the first step (i.e. if stepNum === 0) |
| isLastStep | boolean | Whether the current step is the last step (i.e. if stepNum === history.length - 1) |
| goTo | (step: number) => void | Change the current state to a particular step (index) within the state history |
| goBack | () => void | Go to the previous step (index) within the state history |
| goForward | () => void | Go to the next step (index) within the state history |
| saveCheckpoint | () => void | Saves the current step (index) within the state history to a 'checkpoint' that can be restored later |
| restoreCheckpoint | () => void | Restores the state to the saved 'checkpoint' if it is still valid |
| checkpoint | number | (default 0)
The step (index) within the state history for the saved checkpoint |
| isCheckpointValid | boolean | (default true)
Indicates whether the saved checkpoint is valid and accessible to restore. A checkpoint will be invalidated if the history gets overwritten such that it overwrites the saved checkpoint. History is overwritten when writing new state while at a step number besides the latest. |
| reset | () => void | Resets state, history and checkpoint back to the initial state. |
Please try the codesandbox demo to see an example of the API in action.
Mutation detection
This library expects that mutating logic is only written using the functional update notation within a setState
call. Any attempts to mutate the state outside of this are not supported.
If an uncontrolled mutation is detected, a MutationError
will be thrown (a custom error type exported by this library), and the path detected will be logged to the console to highlight the detected mutation and assist with detecting the cause.
See this codesandbox example to view how the mutation is detected and shown in the console.
Note:
This feature is disabled in production mode.
By default, immer freezes the state recursively after it has been used. This means that attempted mutations will not have an effect, but will not reliably be detected and throw an error for every setup/browser when the attempt is made.
What this means is that the mutation may only be detected in between the first and second state.
This library re-exportssetAutoFreeze
fromimmer
which can help narrow down invalid mutation attempts, as callingsetAutoFreeze(false)
will prevent immer freezing the state, and allow the mutation detection from this library to reliably detect uncontrolled mutations occurring to a serializable state value.
Re-exports
The following items are re-exported from other libraries for ease of use:
setAutoFreeze - Enables / disables automatic freezing of the trees produces. By default enabled.
current - Given a draft object (doesn't have to be a tree root), takes a snapshot of the current state of the draft
original - Given a draft object (doesn't have to be a tree root), returns the original object at the same path in the original state tree, if present
castDraft - Converts any immutable type to its mutable counterpart. This is just a cast and doesn't actually do anything
Draft - Exposed TypeScript type to convert an immutable type to a mutable type
See the following links for more information on the immer API: https://immerjs.github.io/immer/api/
Typescript exports
The following type definitions are used by this library internally and are exported for typescript users to use as required.
/** Initial state provided to the hook */
export declare type InitialState<S> = S | (() => S);
/** New state, or a state updater callback provided to a `setState` call */
export declare type Updates<S> = S | ((draftState: Draft<S>) => Draft<S> | void | undefined);
/** Function used to update the state */
export declare type SetState<S> = (updates: Updates<S>, includeInHistory?: boolean) => void;
/** Extra API used for time travel features */
export declare type ExtraAPI<S> = {
history: readonly S[];
stepNum: number;
isFirstStep: boolean;
isLastStep: boolean;
goTo: (step: number) => void;
goBack: () => void;
goForward: () => void;
saveCheckpoint: () => void;
restoreCheckpoint: () => void;
checkpoint: number;
isCheckpointValid: boolean;
reset: () => void;
};
/** Return value of the hook */
export declare type UseImmerStateReturn<S> = readonly [S, SetState<S>, ExtraAPI<S>];
/**
* Hook similar to useState, but uses immer internally to ensure immutable updates.
* Allows using the setter function to be written 'mutably',
* while letting immer take care of applying the immutable updates.
*
* Provides time travel support including `history`, `checkpoints`, `goTo`,
* and `reset` functionality.
*
* If not in production mode, checks for mutations between renders and will
* throw an error if detected.
*
* https://github.com/Shrugsy/use-immer-state#readme
* @param initialState - initial state, or lazy function to return initial state
* @returns [state, setState, extraAPI]:
* - state - the current state
* - setState- A function to update the state
* - extraAPI - An object containing details and methods related to inbuilt time travel features
*/
export declare function useImmerState<S = undefined>(): UseImmerStateReturn<S | undefined>;
export declare function useImmerState<S>(initialState: InitialState<S>): UseImmerStateReturn<S>;