A tiny, fast, and clever (derogatory) immutable state utility for TypeScript.
npm install patchforkpatchfork has two core functions: patch and fork.
The fork function 'forks' an immutable object, creating a new version with changes applied.
import { fork } from 'patchfork'
fork({ settings: { theme: 'light' } }).settings.theme('dark')
// => { settings: { theme: 'dark' } }
// Pass a function to update a value
fork({ user: { name: 'Nick Cave' } }).user.name(
(name) => name.toUpperCase() + '!!!',
)
// => { user: { name: 'NICK CAVE!!!' } }
// Array, Map, and Set methods work as expected.
fork({ nums: [1, 2, 3] }).nums.push(4)
// => { nums: [1, 2, 3, 4] }So it's kinda like Immer's produce function, but with a wacky API optimized for one-liners.
patch has the same interface as fork, but it operates on a state container to immutably update it.
Any state container with 'get' and 'set' operations can be adapted to work with patch. We provide minimal adapters for React useState, Zustand, and Jotai (PRs welcome for others!).
import { patch } from 'patchfork'
import { usePatchableState } from 'patchfork/react'
function App() {
const [state, store] = usePatchableState({
todos: [
{ text: 'Buy milk', completed: false },
{ text: 'Buy eggs', completed: false },
],
})
const addTodo = (text: string) => {
const text = window.prompt('Enter a todo')
if (text) {
patch(store).todos.push({ text, completed: false })
}
}
const toggleTodo = (index: number) => {
patch(store).todos[index].completed((completed) => !completed)
}
return (
<div>
<button onClick={() => addTodo()}>Add</button>
<ul>
{state.todos.map((todo, i) => (
<li key={i} onClick={() => toggleTodo(i)}>
{todo.text}
</li>
))}
</ul>
</div>
)
}Use fork.do or patch.do to batch operations efficiently.
import { fork, patch } from 'patchfork'
const nextState = fork.do({ user: { name: 'Nick Cave', age: 50 } }, (state) => {
// `state` is a shallow clone of the input object,
// i.e. it's a regular JS object, not a Proxy.
console.log(state) // { user: { name: 'Nick Cave', age: 50 } }
// Call `patch(state)` to make changes.
patch(state).user.name('Nicholas Cage')
patch(state).user.age(51)
// TypeScript will prevent you from doing unsafe mutation.
// ❌ Error: Property 'age' is readonly
state.user.age = 52
// ❌ Error: state.user not patchable
patch(state.user).name('Nicholas Cage')
})fork and patch always do optional chaining. It works just like JavaScript optional chaining. i.e. the whole expression will evaluate to undefined if the operation can't be performed.
import { fork } from 'patchfork'
interface User {
name?: string
settings?: Map<string, string>
}
const user: User = {}
// 'assignment' operations on optional properties always execute.
fork(user).name('Joe')
// => { name: 'Joe' }
// TS type: User
// 'update' operations on optional properties will only execute if the property is not undefined.
fork(user).name((name) => name.toUpperCase())
// => undefined
// TS type: User | undefined
// Collection methods will only succeed if the collection is not undefined or null.
fork(user).settings.set('theme', 'dark')
// => undefined
// TS type: User | undefinedYou can use the ?? operator to perform a fallback operation if the first fails.
const y =
fork(user).name((name) => name.toUpperCase()) ?? fork(user).name('default')
// => { name: 'default' }
// TS type: UserMaps allow keys of any type, so they need a separate syntax.
Use the special key symbol followed by the key itself in parentheses to operate on Map values.
import { key, fork } from 'patchfork'
const state = {
users: new Map([['user1', { name: 'John', age: 30 }]]),
}
const nextState = fork(state).users[key]('user1').name('Wilberforce')
// => { users: new Map([['user1', { name: 'Wilberforce', age: 30 }]]) }patch operations on AsyncPatchable (e.g. the React useState adapter) stores will always return a promise.
Additionally, patch.do and fork.do will return a promise if the callback is async.
If you are checking whether the return value is undefined to perform a fallback operation, remember to await the promise.
const updateTodo = async (title: string) => {
;(await patch.do(store).todos[0].title(title)) ??
patch.do(store).todos.push({ title, completed: false })
}You can also use fork.do and patch.do on nested paths.
import { fork, patch } from 'patchfork'
const state = {
user: {
name: 'Nick Cave',
settings: {
theme: 'light',
fontSize: 16,
},
},
}
const nextState = fork.do(state).user.settings((settings) => {
patch(settings).theme('dark')
patch(settings).fontSize(20)
})TypeScript should prevent unsafely mutating data within patchfork's draft functions if your data is well-typed and you don't use as any ! But who knows what might happen later, in the outside world. Shit's crazy out there.
For extra peace of mind, call setDevMode(true) early in your application's boot process to freeze objects at development time.
This will cause errors to be thrown if you try to mutate an object that is supposed to be immutable.
import { setDevMode } from 'patchfork'
if (process.env.NODE_ENV === 'development') {
setDevMode(true)
}See Custom State Container Integration docs for implementation details and examples.
patchfork seems to be:
- About 5x faster than
immer's production mode. - About 3x faster than
mutative(same API asimmerbut highly optimized)
The benchmarks could be more thorough so take this for what it's worth.
https://github.com/ds300/patchfork/tree/main/bench
-
🩹 No support for patch generation/application.
-
👭 It currently only works with data supported by
structuredClone(So yes ✅ toMap,Set, plain objects, and arrays. And no ❌ to custom classes, objects with symbol keys or getters/setters, etc) -
It currently returns a new object even if an edit is ineffectual, e.g.
const foo = { bar: 'baz' } const nextState = edit(foo).bar('baz') newState !== foo // sadly true
This could be fixed partially for certain usage patterns (PRs welcome).