|
| 1 | +# Immer |
| 2 | + |
| 3 | +[Immer](https://immerjs.github.io/immer/docs/introduction) is a JavaScript package that provides immutability for normal |
| 4 | +JavaScript objects, arrays, Sets, and Maps. After internal review by several of our frontend engineers we've elected |
| 5 | +to add Immer as a dependency and start using it for new development in lieu of [ImmutableJS](https://immutable-js.github.io/immutable-js/). |
| 6 | +That said, we do not plan on actively migrating all usages away from ImmutableJS and it will remain a dependency for |
| 7 | +the foreseeable future. |
| 8 | + |
| 9 | +This document intends to outline why we're moving to Immer, provide some links to good resources for Immer, and provide |
| 10 | +a couple of scenarios highlighting aspects of note. |
| 11 | + |
| 12 | +## Rationale for switching |
| 13 | + |
| 14 | +In April 2020 we took some time to start investigating how we could move |
| 15 | +away from ImmutableJS. We've utilized ImmutableJS since 2015 |
| 16 | +to provide immutable data structures to work with on the client. At the time, it was one of the most well-supported |
| 17 | +immutablility packages out there. Alas, as things progressed ImmutableJS started to fall out of favor for |
| 18 | +several reasons. |
| 19 | + |
| 20 | +#### Disadvantages of ImmutableJS |
| 21 | + |
| 22 | +1. No longer actively developed. We use `v3.8.2` which was released in late 2016. `v4` has yet to get |
| 23 | +passed "release candidate" status with it's most recent candidate releasing in late 2018. |
| 24 | +1. API learning curve. Immutable provides all of its own data structures (e.g. `List`, `Map`, `Set`, etc) which are |
| 25 | +wholy different from native JS data structures. While the API for these structures is powerful, allowing for really |
| 26 | +complex mutations and iterations, it can be difficult to ramp up on understanding it all. |
| 27 | +1. Poorly constructed `Record`. A class we rely on heavily is `Immutable.Record`. Extending record and |
| 28 | +providing the correct typings annotations requires three declarations of each value. Additionally, due to the |
| 29 | +nature of ImmutableJS, the constructor isn't able to make any effectual modifications of what the user passes in, |
| 30 | +which lead to us using a `RecordType.create()` static method pattern. `Immutable.Record` was removed in `v4`. |
| 31 | +1. Difficult to debug. The Immutable data structures can be difficult to debug and generally requires the code to be |
| 32 | +modified to include `.toJS()` statements to understand what is actually held in a data structure. |
| 33 | + |
| 34 | +#### Advantages of Immer |
| 35 | + |
| 36 | +These are copied [directly from the website](https://immerjs.github.io/immer/docs/introduction#benefits): |
| 37 | + |
| 38 | +1. Immutability with normal JavaScript objects, arrays, Sets and Maps. No new APIs to learn! |
| 39 | +1. Strongly typed, no string based paths selectors etc. |
| 40 | +1. Structural sharing out of the box |
| 41 | +1. Object freezing out of the box |
| 42 | +1. Deep updates are a breeze |
| 43 | +1. Boilerplate reduction. Less noise, more concise code. |
| 44 | +1. First class support for patches |
| 45 | +1. Small: 3KB gzipped |
| 46 | + |
| 47 | +## Learning Immer |
| 48 | + |
| 49 | +This document intends to provide some specific insights about using Immer in our LabKey client-side code. As such, it expects |
| 50 | +the reader to have an understanding of why Immer exists, what Immer does, and how Immer does it. |
| 51 | + |
| 52 | +Before you read further it is **highly recommended** that you read (or watch) at least one the following: |
| 53 | + |
| 54 | +- [Immer's official documentation](https://immerjs.github.io/immer/docs/introduction) - read the docs! Most up-to-date and covers lots of topics. |
| 55 | +- [Introducing Immer: Immutability the easy way](https://hackernoon.com/introducing-immer-immutability-the-easy-way-9d73d8f71cb3) - written by the author of Immer |
| 56 | +- [Simplify Creating Immutable Data Trees With Immer](https://egghead.io/lessons/redux-simplify-creating-immutable-data-trees-with-immer) - egghead.io video tutorial |
| 57 | + |
| 58 | +## Scenarios |
| 59 | + |
| 60 | +This section focuses on a couple of scenarios to help get a better understanding. These were written against `v6.0.3` of |
| 61 | +Immer so things may have changed if you're working with a more current version. |
| 62 | + |
| 63 | +### Immutable class |
| 64 | + |
| 65 | +This scenario highlights declaring an immutable class in TypeScript using Immer. By the end we'll have an immutable class |
| 66 | +that is both compile-time and run-time safe. To keep the class simple we're going to define a `Circle` class defined only |
| 67 | +by its `radius`: |
| 68 | + |
| 69 | +```ts |
| 70 | +class Circle { |
| 71 | + radius: number; |
| 72 | + |
| 73 | + constructor(r: number) { |
| 74 | + this.radius = r; |
| 75 | + } |
| 76 | +} |
| 77 | +``` |
| 78 | + |
| 79 | +#### Run-time safety |
| 80 | + |
| 81 | +This initial declaration is fully mutable. You can externally modify the radius after construction. |
| 82 | + |
| 83 | +```ts |
| 84 | +let circle = new Circle(5); |
| 85 | +circle.radius = 10; // radius now 10 |
| 86 | +``` |
| 87 | + |
| 88 | +Let's try to use Immer on this class and see if it works: |
| 89 | + |
| 90 | +```ts |
| 91 | +import { produce } from 'immer'; |
| 92 | + |
| 93 | +let circle = produce(new Circle(5), () => {}); |
| 94 | +circle.radius = 10; // circle.radius is now 10! I thought using produce made it immutable! |
| 95 | +``` |
| 96 | + |
| 97 | +To make a class immutable with Immer you first annotate the class with a [Symbol](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol) |
| 98 | +provided by Immer called `immerable`: |
| 99 | + |
| 100 | +```ts |
| 101 | +import { immerable } from 'immer'; |
| 102 | + |
| 103 | +class Circle { |
| 104 | + [immerable] = true; |
| 105 | + |
| 106 | + radius: number; |
| 107 | + |
| 108 | + constructor(r: number) { |
| 109 | + this.radius = r; |
| 110 | + } |
| 111 | +} |
| 112 | +``` |
| 113 | + |
| 114 | +What does this symbol do? To paraphrase the Immer docs: |
| 115 | + |
| 116 | +> Classes must use the `immerable` symbol to mark itself as compatible with Immer. |
| 117 | +When one of these objects is mutated within a producer, its prototype is preserved between copies. |
| 118 | + |
| 119 | +Now this class is ready to be used with Immer. Let's try again using `produce`: |
| 120 | + |
| 121 | +```ts |
| 122 | +import { produce } from 'immer'; |
| 123 | + |
| 124 | +// without using produce the instance is still mutable |
| 125 | +let circle = new Circle(5); |
| 126 | +circle.radius = 20; // radius now 20. |
| 127 | + |
| 128 | +// with produce the instance is now immutable |
| 129 | +circle = produce(new Circle(5), () => {}); |
| 130 | +circle.radius = 10; // non-strict mode: fails silently. Radius still 5. |
| 131 | +circle.radius = 10; // strict mode: Run-time error: Cannot assign to read only property 'radius' of object '#<Circle>' |
| 132 | +``` |
| 133 | + |
| 134 | +Instances of this class declared via `produce` can only be mutated via `produce`. When an instance is passed through |
| 135 | +Immer's `produce` function it will [freeze](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/freeze) |
| 136 | +the object (when `immer.setAutoFreeze(true)`). Depending on the strict mode, any attempts to explicitly modify the object |
| 137 | +will either fail to modify or throw a run-time error. |
| 138 | + |
| 139 | +Now, let's actually make an update to the immutable instance with Immer: |
| 140 | + |
| 141 | +```ts |
| 142 | +import { produce } from 'immer'; |
| 143 | + |
| 144 | +// Create the initial instance |
| 145 | +const circle = produce(new Circle(5), () => {}); |
| 146 | + |
| 147 | +// Mutate and copy from `produce` |
| 148 | +const newCircle = produce(circle, (draft) => { |
| 149 | + draft.radius = 10; |
| 150 | +}); |
| 151 | +console.log(circle.radius); // 5 |
| 152 | +console.log(newCircle.radius); // 10 |
| 153 | +``` |
| 154 | + |
| 155 | +The instance is now immutable, a mutated copy can be made via `produce`, and we have run-time safety via `Object.freeze`. |
| 156 | + |
| 157 | +#### Compile-time safety |
| 158 | + |
| 159 | +Immer results in run-time safety from mutations to your objects, but used in conjunction with Typescript you can |
| 160 | +also get compile-time safety. This has the advantages of catching errors earlier and applying to all code paths, |
| 161 | +even those not covered by tests. |
| 162 | + |
| 163 | +To get started, let's first declare all the properties on the `Circle` class as [`readonly`](https://www.typescriptlang.org/docs/handbook/classes.html#readonly-modifier). |
| 164 | + |
| 165 | +```ts |
| 166 | +import { immerable } from 'immer'; |
| 167 | + |
| 168 | +class Circle { |
| 169 | + [immerable] = true; |
| 170 | + |
| 171 | + readonly radius: number; |
| 172 | + |
| 173 | + constructor(r: number) { |
| 174 | + this.radius = r; |
| 175 | + } |
| 176 | +} |
| 177 | +``` |
| 178 | + |
| 179 | +The `radius` property is now read-only so if we attempt to modify it directly we receive an error: |
| 180 | + |
| 181 | +```ts |
| 182 | +import { produce } from 'immer'; |
| 183 | + |
| 184 | +let circle = new Circle(5); |
| 185 | +circle.radius = 5; // Error: TS2540: Cannot assign to 'radius' because it is a read-only property. |
| 186 | + |
| 187 | +// Same for the produced version |
| 188 | +let circle = produce(new Circle(5), () => {}); |
| 189 | +circle.radius = 5; // Error: TS2540: Cannot assign to 'radius' because it is a read-only property. |
| 190 | +``` |
| 191 | + |
| 192 | +This gives us compile-time safety against invalid writes. The next feature we can use is the `Draft` utility from |
| 193 | +Immer. `Draft` To quote the docs: |
| 194 | + |
| 195 | +> The `Draft` utility type can be used if the state argument type is immutable. |
| 196 | +
|
| 197 | +```ts |
| 198 | +import { Draft, produce } from 'immer'; |
| 199 | + |
| 200 | +// Without "Draft" |
| 201 | +let circle = produce(new Circle(5), (draft: Circle) => { |
| 202 | + draft.radius = 10; // Error: TS2540: Cannot assign to 'radius' because it is a read-only property. |
| 203 | +}); |
| 204 | + |
| 205 | +// With "Draft" |
| 206 | +let circle = produce(new Circle(5), (draft: Draft<Circle>) => { |
| 207 | + draft.radius = 10; // OK! |
| 208 | +}); |
| 209 | +``` |
| 210 | + |
| 211 | +If you'd like to have your class instances be immutable without requiring use of `produce` you can directly call |
| 212 | +`Object.freeze` at the end of the constructor. |
| 213 | + |
| 214 | +```ts |
| 215 | +import { immerable } from 'immer'; |
| 216 | + |
| 217 | +class Circle { |
| 218 | + [immerable] = true; |
| 219 | + |
| 220 | + readonly radius: number; |
| 221 | + |
| 222 | + constructor(r: number) { |
| 223 | + this.radius = r; |
| 224 | + |
| 225 | + // Optionally, freeze the instance. Still works with produce but doesn't require it to have |
| 226 | + // an immutable instance via construction. |
| 227 | + Object.freeze(this); |
| 228 | + } |
| 229 | +} |
| 230 | +``` |
| 231 | + |
| 232 | +Now we have an immutable class that is compile-time safe, run-time safe, and can be utilized by Immer. |
0 commit comments