diff --git a/.vscode/settings.json b/.vscode/settings.json index 6f4faab4..9f74d054 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,6 @@ { "javascript.validate.enable": false, "typescript.tsdk": "node_modules/typescript/lib", - "jest.enableInlineErrorMessages": true + "jest.enableInlineErrorMessages": true, + "cSpell.enabled": true } diff --git a/website/docs/curried-produce.mdx b/website/docs/curried-produce.mdx index 68c88c3a..48d82dba 100644 --- a/website/docs/curried-produce.mdx +++ b/website/docs/curried-produce.mdx @@ -9,7 +9,8 @@ title: Curried producers data-ea-type="image" className="horizontal bordered" > -
+ +
egghead.io lesson 6: Simplify code by using curried _reduce_ @@ -30,56 +31,50 @@ title: Curried producers
-Passing a function as the first argument to `produce` is intended to be used for currying. This means that you get a pre-bound producer that only needs a state to produce the value from. The producer function gets passed in the draft and any further arguments that were passed to the curried function. - -For example: +Passing a function as the first argument to `produce` creates a function that doesn't apply `produce` yet to a specific state, but rather creates a function that will apply `produce` to any state that is passed to it in the future. This generally is called _currying_. Take for example the following example: ```javascript -// mapper will be of signature (state, index) => state -const mapper = produce((draft, index) => { - draft.index = index -}) +import produce from "immer" + +function toggleTodo(state, id) { + return produce(state, draft => { + const todo = draft.find(todo => todo.id === id) + todo.done = !todo.done + }) +} + +const baseState = [ + { + id: "JavaScript", + title: "Learn TypeScript", + done: true + }, + { + id: "Immer", + title: "Try Immer", + done: false + } +] -// example usage -console.dir([{}, {}, {}].map(mapper)) -//[{index: 0}, {index: 1}, {index: 2}]) +const nextState = toggleTodo(baseState, "Immer") ``` -This mechanism can also nicely be leveraged to further simplify our example reducer: +The above pattern of `toggleTodo` is quite typical; pass an existing state to `produce`, modify the `draft`, and then return the result. Since `state` isn't used for anything else than passing it on to `produce`, the above example can be simplified by using the _curried_ form of `produce`, where you pass `produce` only the recipe function, and `produce` will return a new function that will apply recipe to the base state. This allows us to shorten the above `toggleTodo` definition. ```javascript import produce from "immer" -const byId = produce((draft, action) => { - switch (action.type) { - case RECEIVE_PRODUCTS: - action.products.forEach(product => { - draft[product.id] = product - }) - return - } +// curried producer: +const toggleTodo = produce((draft, id) => { + const todo = draft.find(todo => todo.id === id) + todo.done = !todo.done }) -``` - -Note that `state` is now factored out (the created reducer will accept a state, and invoke the bound producer with it). - -If you want to initialize an uninitialized state using this construction, you can do so by passing the initial state as second argument to `produce`: -```javascript -import produce from "immer" +const baseState = [ + /* as is */ +] -const byId = produce( - (draft, action) => { - switch (action.type) { - case RECEIVE_PRODUCTS: - action.products.forEach(product => { - draft[product.id] = product - }) - return - } - }, - { - 1: {id: 1, name: "product-1"} - } -) +const nextState = toggleTodo(baseState, "Immer") ``` + +Note that the `id` param has now become part of the recipe function! This pattern of having curried producers combines really neatly with for example the `useState` hook from React, as we will see on the next page. diff --git a/website/docs/example-reducer.mdx b/website/docs/example-reducer.mdx deleted file mode 100644 index 1f3d14d0..00000000 --- a/website/docs/example-reducer.mdx +++ /dev/null @@ -1,79 +0,0 @@ ---- -id: example-reducer -title: Example Reducer ---- - -
-
-
-
- - egghead.io lesson 13: Using Immer in a (React) reducer - -
-
- -
- - Hosted on egghead.io - -
- -Here is a simple example of the difference that Immer could make in practice. - -```javascript -// Reducer with initial state -const INITIAL_STATE = {} -// Shortened, based on: https://github.com/reactjs/redux/blob/master/examples/shopping-cart/src/reducers/products.js -const byId = (state = INITIAL_STATE, action) => { - switch (action.type) { - case RECEIVE_PRODUCTS: - return { - ...state, - ...action.products.reduce((obj, product) => { - obj[product.id] = product - return obj - }, {}) - } - default: - return state - } -} -``` - -After using Immer, our reducer can be expressed as: - -```javascript -import produce from "immer" - -// Reducer with initial state -const INITIAL_STATE = {} - -const byId = produce((draft, action) => { - switch (action.type) { - case RECEIVE_PRODUCTS: - action.products.forEach(product => { - draft[product.id] = product - }) - break - } -}, INITIAL_STATE) -``` - -Notice that it is not necessary to handle the default case, a producer that doesn't do anything will return the original state. - -Creating Redux reducer is just a sample application of the Immer package. Immer is not just designed to simplify Redux reducers. It can be used in any context where you have an immutable data tree that you want to clone and modify (with structural sharing). - -_Note: it might be tempting after using producers for a while, to just place `produce` in your root reducer and then pass the draft to each reducer and work directly over said draft. Don't do that. It removes the benefit of using Redux as a system where each reducer is testable as a pure function. Immer is best used when applied to small, individual pieces of logic._ diff --git a/website/docs/example-setstate.mdx b/website/docs/example-setstate.mdx index 59b56401..7bd6a3bd 100644 --- a/website/docs/example-setstate.mdx +++ b/website/docs/example-setstate.mdx @@ -1,6 +1,6 @@ --- id: example-setstate -title: React setState example +title: React & Immer ---
@@ -9,7 +9,8 @@ title: React setState example data-ea-type="image" className="horizontal bordered" > -
+ +
egghead.io lesson 8: Using Immer with _useState_. Or: _useImmer_ @@ -30,30 +31,209 @@ title: React setState example
-Deep updates in the state of React components can be greatly simplified as well by using immer. Take for example the following onClick handlers (Try in [codesandbox](https://codesandbox.io/s/m4yp57632j)): +## useState + Immer + +The `useState` hook assumes any state that is stored inside it is treated as immutable. Deep updates in the state of React components can be greatly simplified as by using Immer. To following example shows how to use `produce` in combination with `useState`, and can be tried on [CodeSandbox](https://codesandbox.io/s/immer-usestate-ujkgg?file=/src/index.js). ```javascript -/** - * Classic React.setState with a deep merge - */ -onBirthDayClick1 = () => { - this.setState(prevState => ({ - user: { - ...prevState.user, - age: prevState.user.age + 1 - } - })) +import React, { useCallback, useState } from "react"; +import produce from "immer"; + +const TodoList = () => { + const [todos, setTodos] = useState([ + { + id: "React", + title: "Learn React", + done: true + }, + { + id: "Immer", + title: "Try Immer", + done: false + } + ]); + + const handleToggle = useCallback((id) => { + setTodos( + produce((draft) => { + const todo = draft.find((todo) => todo.id === id); + todo.done = !todo.done; + }) + ); + }, []); + + const handleAdd = useCallback(() => { + setTodos( + produce((draft) => { + draft.push({ + id: "todo_" + Math.random(), + title: "A new todo", + done: false + }); + }) + ); + }, []); + + return (
{*/ See CodeSandbox */}
) } +``` -/** - * ...But, since setState accepts functions, - * we can just create a curried producer and further simplify! - */ -onBirthDayClick2 = () => { - this.setState( - produce(draft => { - draft.user.age += 1 - }) +## useImmer + +Since all state updaters follow the same pattern where the update function is wrapped in `produce`, it is also possible to simply the above by leveraging the [use-immer](https://www.npmjs.com/package/use-immer) package that will wrap updater functions in `produce` automatically: + +```javascript +import React, { useCallback } from "react"; +import { useImmer } from "use-immer"; + +const TodoList = () => { + const [todos, setTodos] = useImmer([ + { + id: "React", + title: "Learn React", + done: true + }, + { + id: "Immer", + title: "Try Immer", + done: false + } + ]); + + const handleToggle = useCallback((id) => { + setTodos((draft) => { + const todo = draft.find((todo) => todo.id === id); + todo.done = !todo.done; + }); + }, []); + + const handleAdd = useCallback(() => { + setTodos((draft) => { + draft.push({ + id: "todo_" + Math.random(), + title: "A new todo", + done: false + }); + }); + }, []); + + // etc +``` + +For the full demo see [CodeSandbox](https://codesandbox.io/s/use-immer-bvd5v?file=/src/index.js). + +## useReducer + Immer + +Similarly to `useState`, `useReducer` combines neatly with Immer as well, as demonstrated in this [CodeSandbox](https://codesandbox.io/s/immer-usereducer-bqpzn?file=/src/index.js:0-1018): + +```javascript +import React, {useCallback, useReducer} from "react" +import produce from "immer" + +const TodoList = () => { + const [todos, dispatch] = useReducer( + produce((draft, action) => { + switch (action.type) { + case "toggle": + const todo = draft.find(todo => todo.id === action.id) + todo.done = !todo.done + break + case "add": + draft.push({ + id: "todo_" + Math.random(), + title: "A new todo", + done: false + }) + break + default: + break + } + }), + [ + /* initial todos */ + ] ) + + const handleToggle = useCallback(id => { + dispatch({ + type: "toggle", + id + }) + }, []) + + const handleAdd = useCallback(() => { + dispatch({ + type: "add" + }) + }, []) + + // etc } ``` + +## useImmerReducer + +...which again, can be slightly shorted by `useImmerReducer` from the `use-immer` package ([demo](https://codesandbox.io/s/useimmerreducer-sycpb?file=/src/index.js)): + +```javascript +import React, { useCallback } from "react"; +import { useImmerReducer } from "use-immer"; + +const TodoList = () => { + const [todos, dispatch] = useImmerReducer( + (draft, action) => { + switch (action.type) { + case "toggle": + const todo = draft.find((todo) => todo.id === action.id); + todo.done = !todo.done; + break; + case "add": + draft.push({ + id: "todo_" + Math.random(), + title: "A new todo", + done: false + }); + break; + default: + break; + } + }, + [ /* initial todos */ ] + ); + + //etc + +``` + +## Redux + Immer + +Redux + Immer is extensively covered in the documentation of [Redux Toolkit](https://redux-toolkit.js.org/usage/immer-reducers). For Redux without Redux Toolkit, the same trick as applied to `useReducer` above can be applied: wrap the reducer function with `produce`, and you can safely mutate the draft! + +For example: + +```javascript +import produce from "immer" + +// Reducer with initial state +const INITIAL_STATE = [ + /* bunch of todos */ +] + +const todosReducer = produce((draft, action) => { + switch (action.type) { + case "toggle": + const todo = draft.find(todo => todo.id === action.id) + todo.done = !todo.done + break + case "add": + draft.push({ + id: "todo_" + Math.random(), + title: "A new todo", + done: false + }) + break + default: + break + } +}) +``` diff --git a/website/docs/introduction.md b/website/docs/introduction.md index 44f0907f..62a7ecf4 100644 --- a/website/docs/introduction.md +++ b/website/docs/introduction.md @@ -21,47 +21,84 @@ Winner of the "Breakthrough of the year" [React open source award](https://osawa --- -Immer (German for: always) is a tiny package that allows you to work with immutable state in a more convenient way. It is based on the [_copy-on-write_](https://en.wikipedia.org/wiki/Copy-on-write) mechanism. +Immer (German for: always) is a tiny package that allows you to work with immutable state in a more convenient way. -The basic idea is that you will apply all your changes to a temporary _draftState_, which is a proxy of the _currentState_. Once all your mutations are completed, Immer will produce the _nextState_ based on the mutations to the draft state. This means that you can interact with your data by simply modifying it while keeping all the benefits of immutable data. +### Immer simplifies handling immutable data structures -![immer-hd.png](/img/immer.png) +Immer can be used in any context in which immutable data structures need to be used. For example in combination with React state, React or Redux reducers, or configuration management. Immutable data structures allow for (efficient) change detection: if the reference to an object didn't change, the object itself did not change. In addition, it makes cloning relatively cheap: Unchanged parts of a data tree don't need to be copied and are shared in memory with older versions of the same state. -Using Immer is like having a personal assistant. The assistant takes a letter (the current state) and gives you a copy (draft) to jot changes onto. Once you are done, the assistant will take your draft and produce the real immutable, final letter for you (the next state). +Generally speaking, these benefits can be achieved by making sure you never change any property of an object, array or map, but by always creating an altered copy instead. In practice this can result in code that is quite cumbersome to write, and it is easy to accidentally violate those constraints. Immer will help you to follow the immutable data paradigm by addressing these pain points: -## Quick Example +1. Immer will detect accidental mutations and throw an error. +2. Immer will remove the need for the typical boilerplate code that is needed when creating deep updates to immutable objects: Without Immer, object copies need to be made by hand at every level. Typically by using a lot of `...` spread operations. When using Immer, changes are made to a `draft` object, that records the changes and takes care of creating the necessary copies, without ever affecting the original object. +3. When using Immer, you don't need to learn dedicated APIs or data structures to benefit from the paradigm. With Immer you'll use plain JavaScript data structures, and use the well know mutable JavaScript APIs, but safely. -```javascript -import produce from "immer" +### A quick example for comparison +```javascript const baseState = [ { - todo: "Learn typescript", + title: "Learn TypeScript", done: true }, { - todo: "Try immer", + title: "Try Immer", done: false } ] +``` + +Imagine we have the above base state, and we'll need to update the second todo, and add a third one. However, we don't want to mutate the original `baseState`, and we want to avoid deep cloning as well (to preserve the first todo). + +#### Without Immer + +Without Immer, we'll have to carefully shallow copy every level of the state structure that is affected by our change: + +```javascript +const nextState = baseState.slice() // shallow clone the array +nextState[1] = { + // replace element 1... + ...nextState[1], // with a shallow clone of element 1 + done: true // ...combined with the desired update +} +// since nextState was freshly cloned, using push is safe here, +// but doing the same thing at any arbitrary time in the future would +// violate the immutability principles and introduce a bug! +nextState.push({title: "Tweet about it"}) +``` -const nextState = produce(baseState, draftState => { - draftState.push({todo: "Tweet about it"}) - draftState[1].done = true +#### With Immer + +With Immer, this process is more straightforward. We can leverage the `produce` function, which takes as first argument the state we want to start from, and as second argument we pass a function, called the _recipe_, that is passed a `draft` to which we can apply straightforward mutations. Those mutations are recorded and used to produce the next state once the recipe is done. `produce` will take care of all the necessary copying, and protect against future accidental modifications as well by freezing the data. + +```javascript +import produce from "immer" + +const nextState = produce(baseState, draft => { + draft[1].done = true + draft.push({title: "Tweet about it"}) }) ``` -The interesting thing about Immer is that the `baseState` will be untouched, but the `nextState` will be a new immutable tree that reflects all changes made to `draftState` (and structurally sharing the things that weren't changed). +Looking for Immer in combination with React? Feel free to skip ahead to the [React + Immer](example-setstate) page. + +### How Immer works + +The basic idea is that with Immer you will apply all your changes to a temporary _draft_, which is a proxy of the _currentState_. Once all your mutations are completed, Immer will produce the _nextState_ based on the mutations to the draft state. This means that you can interact with your data by simply modifying it while keeping all the benefits of immutable data. + +![immer-hd.png](/img/immer.png) + +Using Immer is like having a personal assistant. The assistant takes a letter (the current state) and gives you a copy (draft) to jot changes onto. Once you are done, the assistant will take your draft and produce the real immutable, final letter for you (the next state). Head to the [next section](./produce.mdx) to further dive into `produce`. ## Benefits -- Immutability with normal JavaScript objects, arrays, Sets and Maps. No new APIs to learn! +- Follow the immutable data paradigm, while using normal JavaScript objects, arrays, Sets and Maps. No new APIs or "mutation patterns" to learn! - Strongly typed, no string based paths selectors etc. - Structural sharing out of the box - Object freezing out of the box - Deep updates are a breeze - Boilerplate reduction. Less noise, more concise code. -- First class support for patches +- First class support for JSON patches - Small: 3KB gzipped diff --git a/website/docs/produce.mdx b/website/docs/produce.mdx index 4757fda8..539d479b 100644 --- a/website/docs/produce.mdx +++ b/website/docs/produce.mdx @@ -9,8 +9,7 @@ title: Using produce data-ea-type="image" className="horizontal bordered" > - -
+
egghead.io lesson 3: Simplifying deep updates with _produce_ @@ -33,9 +32,15 @@ title: Using produce The Immer package exposes a default function that does all the work. -`produce(currentState, producer: (draftState) => void): nextState` +`produce(currentState, recipe: (draftState) => void): nextState` -There is also a curried overload that is explained [in a later section](./curried-produce.mdx). +`produce` takes a base state, and a _recipe_ that can be used perform all the desired mutations on the `draft` that is passed in. The interesting thing about Immer is that the `baseState` will be untouched, but the `nextState` will reflect all changes made to `draftState`. + +Inside the recipe, all standard JavaScript APIs can be used on the `draft` object, including field assignments, `delete` operations, and mutating array, Map and Set operations like `push`, `pop`, `splice`, `set`, `sort`, `remove`, etc. + +Any of those mutations don't have to happen at the root, but it is allowed to modify anything anywhere deep inside the draft: `draft.todos[0].tags["urgent"].author.age = 56` + +Note that the recipe function itself normally doesn't return anything. However, it is possible to return in case you want to replace the `draft` object in it's entirety with another object, for more details see [returning new data](return). ## Example @@ -44,23 +49,21 @@ import produce from "immer" const baseState = [ { - todo: "Learn typescript", + title: "Learn TypeScript", done: true }, { - todo: "Try immer", + title: "Try Immer", done: false } ] const nextState = produce(baseState, draftState => { - draftState.push({todo: "Tweet about it"}) + draftState.push({title: "Tweet about it"}) draftState[1].done = true }) ``` -The interesting thing about Immer is that the `baseState` will be untouched, but the `nextState` will reflect all changes made to `draftState`. - ```javascript // the new item is only added to the next state, // base state is unmodified @@ -73,6 +76,15 @@ expect(nextState[1].done).toBe(true) // unchanged data is structurally shared expect(nextState[0]).toBe(baseState[0]) -// changed data not (dûh) +// ...but changed data isn't. expect(nextState[1]).not.toBe(baseState[1]) ``` + +### Terminology + +- `(base)state`, the immutable state passed to `produce` +- `recipe`: the second argument of `produce`, that captures how the base state should be "mutated". +- `draft`: the first argument of any `recipe`, which is a proxy to the original base state that can be safely mutated. +- `producer`. A function that uses `produce` and is generally of the form `(baseState, ...arguments) => resultState` + +Note that it isn't strictly necessary to name the first argument of the recipe `draft`. You can name it anything you want, for example `users`. Using `draft` as a name is just a convention to signal: "mutation is OK here". diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 5b6adeea..1862b2e6 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -1,4 +1,4 @@ -// See https://v2.docusaurus.io/docs/configuration for more information +// See https://v2.docusaurus.io/docs/configuration for more information. module.exports = { title: "Immer", diff --git a/website/sidebars.js b/website/sidebars.js index 77486e80..77c2ef93 100755 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -5,7 +5,6 @@ module.exports = { "installation", "produce", "curried-produce", - "example-reducer", "example-setstate", "update-patterns" ],