@cutetn/react-mustable v1.0.8
react-mustable
A package to work with React Typescript mutable states.
Table of Content
- Introduction
- Installation
- Usage
- License
Introduction
The benefits of immutability is widely proven and accepted in programming world. Although the fact is not deniable, it comes with a cost on performance.
React-mustable tries to break the rule of immutability of React. It offers syntactic sugar to work with mutable states, while keeping them works as perfectly as React states.
The word "mustable" is a pun on the words "mutable" and "must stable", implying the whole purposes of the package. However, from now on, whenever the word appears in this article, you can interpret it as "mutable and observable by React".
Installation
npm i @cutetn/react-mustableor
yarn add @cutetn/react-mustableUsage
1. Mustable Classes
1.1. MustableBase
MustableBaseis an abstract class to interact withreact-mustableinternal logics and React APIs.MustableBasecan be imported directly from the package.
1.2. Custom Mustable Class
- To utilize
react-mustableAPIs, you must create a class for your state, which extendsMustableBaseclass. - You can create any class members you want, including the constructor, methods, fields and properties. The only caveat is you can't access to
versionandinstanceas it is used byreact-mustableunder the hood. if you try to access these members, some editors (VSCode for instance) should warn you not to do it by marking it as deprecated.
import { MustableBase } from "@cutetn/react-mustable"
class CustomMustable extends MustableBase {
myCustomField: string;
get myCustomProperty(): string {
return this.myCustomField;
}
set myCustomProperty(value) {
this.myCustomField = value;
}
myCustomMethod() {
console.log(this.myCustomProperty);
}
}1.3. mustable decorator
mustabledecorator marks a member as a mustable member of a Mustable class, i.e. every access to these members can be observed by React, thus, triggering re-rendering. Therefore, you should use this decorator on every single member whose changes can effect the UI.- A member, which is decorated by
mustable, almost changes nothing in its behavior itself. You are free to write unit tests to the class as if it was a normal class. - However, it is recommended that mustable functions should NOT return anything. It is because there is a different behavior when using a Mustable instance along with React APIs, which would be covered in later sections.
- First off, to use decorators in TypeScript, be sure to have your
tsconfig.jsoncorrectly:
{
"compilerOptions": {
"target": "es5",
"experimentalDecorators": true
}
}Never mind the word "experimental", Angular has been using decorators for ages.
1.3.1. Mustable methods
- A mustable method is simply a class method decorated with
mustable.
import { MustableBase, mustable } from "@cutetn/react-mustable"
export class CustomMustable extends MustableBase {
@mustable()
myMutableMethod() {
// do some mutation...
}
}- As said previously, you should only decorate your method with
@mustableas long as a method call mutates your UI data. Also, do not return anything to avoid any confusion later on. - If the method is not decorated with
@mustable, you are free to return anything as the method would always act normally.
1.3.2. Mustable Fields and Properties
- A mustable field is simply a class field decorated with
mustable. A mustable property is a class property whose eithergetterorsetterdecorated withmustable.
import { MustableBase, mustable } from "@cutetn/react-mustable"
export class CustomMustable extends MustableBase {
@mustable()
myCustomField: string = "";
@mustable()
get myCustomProperty(): string {
return this.myCustomField;
}
set myCustomProperty(value) {
this.myCustomField = value;
}
}- you should only decorate your fields and properties with
@mustableas long as setting its value mutates your UI data. - Note that a mutation on a mustable member, inside of other immustable member will not be visible by the
react-mustablesystem.
import { MustableBase, mustable } from "@cutetn/react-mustable"
export class CustomMustable extends MustableBase {
@mustable()
myCustomField: string = "";
mutate(value: string) {
myCustomField = value;
// This won't trigger re-rendering even though "myCustomField" itself is mustable.
// To fix this, "mutate" method must be mustable as well.
}
}1.3.3. isMustableFunction option
- Given the scenario where you have a field whose type is a function, AND this function mutate your UI data.
import { MustableBase, mustable } from "@cutetn/react-mustable"
export class CustomMustable extends MustableBase {
@mustable()
myFunctionField = () => {
// do some mutation...
}
}- Unfortunately, a function call to
myFunctionFieldwill NOT trigger React to re-render in this case. The library only watches a set of fields and properties or a method call, not a "field-which-is-actually-a-function call". Therefore, by the way, a set tomyFunctionFieldwould be observable by React. - If you really want this method call to trigger React to re-render, you can either refactor it into a method, or set the option
isMustableFunctiontotrue.
import { MustableBase, mustable } from "@cutetn/react-mustable"
export class CustomMustable extends MustableBase {
@mustable({
isMustableFunction: true
})
myFunctionField = () => {
// do some mutation...
}
}- Note that even with this option turned on, a set to the
myFunctionFieldstill trigger React to re-render.
1.3.4. snapshot option
- A function to produce a lightweight immutable snapshot from your Mustable instance. When provided, 2 snapshots will be created before and after a mustable operation is done. React will then compare these 2 snapshots to decide if it should re-render the UI.
- The
snapshotfunction accepts the first paramemter as the instance on which the mutation is functioning on. The second parameter is an array, is the list of arguments that the mutation option is called with. - For example, if we have a method
mutateField(fieldName: string, value: string)to mutate an internal object fieldfieldName, and another immutable methodgetField(fieldName: string)to get the field from the object. We know that the methodmutateFieldonly mutatethis.obj[fieldName], which can be obtained bygetField. We can actually use the methodgetFieldto create a snapshot formutateField. Notice that if the new value ofthis.obj[fieldName]is the same as its old one, a re-render would be automatically skipped, which optimize the performance for your website.
import { MustableBase, mustable } from "@cutetn/react-mustable"
export class CustomMustable extends MustableBase {
private obj = {};
getField(fieldName: string) {
return this.obj[fieldName];
}
@mustable({
snapshot: (instance, args = []) => instance.getField(args[0])
})
mutateField(fieldName: string, value: string) {
this.obj[fieldName] = value;
}
}- If the
snapshotoption is omitted, the system would treat it as "always changing" operation. - Note that you always only need the public interface of your class to provide a snapshot. If a mutation does not change a thing to the public interface, how can it reflect changes to the UI?
1.3.5. sameSnapshotsChecker option
- It's sometimes reasonable to have a custom
snapshotcomparer. Using this comparer wisely along withsnapshotoption would benefits the web's performance a lot. - Intuitively, the
sameSnapshotsCheckerreceives 2 snapshots, one before the mutation, one after, and return a boolean indicating if the 2 snapshots should be seen as identical.
import { MustableBase, mustable } from "@cutetn/react-mustable"
class Person {
id: string;
name: string;
age: number;
isSame(other: Person) {
return this.name === other.name && this.age === other.age
}
}
export class CustomMustable extends MustableBase {
private people = {};
getPerson(id: string) {
return this.people[id];
}
@mustable({
snapshot: (instance, args = []) => instance.getPerson(args[0]),
sameSnapshotsChecker:
(personBefore, personAfter) =>
personBefore.isSame(personAfter),
})
mutateField(person: Person) {
this.people[person.id] = person;
}
}- Because rewriting these comparers would be a waste of time,
react-mustableprovides some basic comparison strategy in thesameSnapshotsCheckersobject.isAlwaysChanging: Always return true.isShallowSame: Shallow equality, compare values of primitive types, and references of complex types.isDeepSame: Deep equality, compare values of primitive types, otherwise, it would compare each field nested in a complex type.isTopLevelArrayShallowSame: If the 2 snapshots are array, it checks for shallow equality of each element in the array; check for shallow equality otherwise. This is similar to React's dependencies list comparison.
- If
sameSnapshotsCheckeroption is omitted whilesnapshotis provided, theisTopLevelArrayShallowSamewould be taken as default strategy.
1.4. immustable decorator
- The
immustabledecorator does nothing to your members. Nonetheless, it is recommended to use this decorator as a safe checkpoint on animmustablemember, ensuring never forgetting anymustablemembers.
1.5. Built-in Mustable Classes
react-mustablepackages some built-in Mustable Classes. These are wrappers for JavaScript's built-in data structures, including:MustableArray,MustableSetandMustableMap.- The usage of these classes are almost similar to its original version, with a few key differences:
- Mutable methods return
void. - An instance of
MustableArraydoes not have the[index]operator. You must use theat(index: number)method instead. - An instance of these classes can not be iterated with spread operator (
...). Try converting them back to JavaScript objects or use other method if possible. - Some other helper methods.
- Mutable methods return
2. Using Mustable states
2.1. useMustableRegistry hook
- This hook create a "Mustable Registry" object for your component. This object manage all mustable instances and provide functionality to add new or clean up them.
- Using this hook exactly once in your component is enough for any mustable logics, even though you could create more, it would be pointless.
- You shouldn't provide the Mustable registry object in a React context provider, as it would excessively try to re-render your entire UI hierarchy, creating a huge impact on your web's performance.
2.2. register and remove functions
registerfunction of the registry object add a wrapper calledReact-Mustableinstance to a Mustable instance. This instance is responsible for keeping the mustable state sync to React.- Every mustable functions and methods of the wrapper returns
voidregardless to its original declaration in the Mustable class, even though the editor's type system suggests that the return type remains intact. This is the reason why every mustable member should returnvoidto avoid this confusion. - The first parameter of
registeris a Mustable instance to create a wrapper to. The second parameter, namelykeepRef, tells system whether it should save the reference to the registry object to the wrapper for later use, this value defaults totrue. - Once the reference to wrapper is saved, every other call of
registerto the same instance would give you the same React-Mustable instance. - To remove a saved React-Mustable wrapper instance, call
removefunction on the registry object. This function accepts either a Mustable instance or a React-Mustable instance.
import React from "react";
import { useMustableRegistry, MustableArray } from "@cutetn/react-mustable"
function MyComponent() {
const mustableReg = useMustableRegistry();
// "mustableArray" is now actually a React-Mustable instance.
const mustableArray = React.useMemo(() =>
mustableReg.register(new MustableArray())
, []);
React.useEffect(() => {
// Clean up the mustable instance on unmount.
return () => mustableReg.remove(mustableArray);
}, [])
return <></>
}2.3. useMustable and useNullableMustable hook
- It can be noticed that this chunk of code (below) appears frequently as you may want to create some states for your component.
const mustableArray = React.useMemo(() =>
mustableReg.register(new MustableArray())
, []);
React.useEffect(() => {
return () => mustableReg.remove(mustableArray);
}, [])- The registry object has another hook
useMustableto do all of these works. - The code snippet given in the section 2.2 can be refactored as:
import React from "react";
import { useMustableRegistry, MustableArray } from "@cutetn/react-mustable"
function MyComponent() {
const mustableReg = useMustableRegistry();
// "mustableArray" is actually a React-Mustable instance.
const mustableArray = mustableReg.useMustable(
() => new MustableArray(),
[]
);
return <></>
}- The first parameter is a Mustable factory, which is a function receives no parameter and returns a Mustable instance. The second parameter is the dependencies list, whenever there are some changes to this list, the factory would create another Mustable instance based on new data. This hook creates and returns a React-Mustable wrapper for the produced Mustable instance.
- Similarly,
useNullableMustableworks almost the same, except that the factory may returnnullorundefined; In those cases, the hook would just return the same product as the factory.
2.4. Mutating states using mustable members
- Once you have got a React-Mustable instance, every interaction with mustable members of the wrapped Mustable instance would trigger React to re-render.
- Throughout this section, we are going to use the class
Workerbelow:
import { MustableBase, immustable, mustable } from "@cutetn/react-mustable";
class Worker extends MustableBase {
constructor(name: string, manager?: Worker) {
super();
this.name = name;
this.manager = manager;
}
@mustable({
snapshot: (instance) => instance.name,
})
name: string;
private _energy: number = 10;
get energy() {
return this._energy;
}
@mustable()
manager?: Worker;
@mustable({
snapshot: (instance) => instance.energy,
})
work() {
if (this.energy > 0) this._energy--;
}
@mustable({
snapshot: (instance) => instance.energy,
})
eat() {
if (this.energy < 10) this._energy++;
}
@immustable()
toObject() {
return {
name: this.name,
energy: this.energy,
manager: this.manager?.toObject(),
};
}
@immustable()
toString() {
return JSON.stringify(this.toObject(), null, 2);
}
}- Let's first create a simple component using
Workerclass:
import React from "react"
import { useMustableRegistry } from "@cutetn/react-mustable"
function MyComponent() {
const mustableReg = useMustableRegistry();
const worker = mustableReg.useMustable(() => new Worker("Bob", new Worker("Alice")), []);
const handleChangeWorkerName = React.useCallback((e) => {
worker.name = e.target.value;
}, []);
return (
<>
<div style={{ whiteSpace: "pre-wrap", fontFamily: "monospace" }}>
{worker.toString()}
</div>
<div>
<label>Worker's name: </label>
<input onChange={handleChangeWorkerName} value={worker.name} />
</div>
<div>
<button onClick={worker.eat}>{worker.name} eats</button>
</div>
<div>
<button onClick={worker.work}>{worker.name} works</button>
</div>
</>
);
}- Notice that setting new value to
worker.name, or callingworker.work,worker.eatwould mutate theworkerinstance. The new data immediately reflects to the UI even though it does not look like a regular React'ssetState. - Voilà, our first working example!
2.4.1. NOT the same behavior as React states
- React's
setStatedoes not update state immediately. Instead, the new state is only set once the rendering phase is done. [ref] React's queue a series of
setStatefor one re-render. [ref]Let's verify these behaviors in
react-mustable:
import React from "react"
import { useMustableRegistry } from "@cutetn/react-mustable"
function MyComponent() {
const mustableReg = useMustableRegistry();
const worker = mustableReg.useMustable(() => new Worker("Bob", new Worker("Alice")), []);
const handleTripleWork = React.useCallback(() => {
console.log("Before works", worker.energy);
worker.work();
console.log("After work 1", worker.energy);
worker.work();
console.log("After work 2", worker.energy);
worker.work();
console.log("After work 3", worker.energy);
}, []);
console.log("rerender!");
return (
<>
<div style={{ whiteSpace: "pre-wrap", fontFamily: "monospace" }}>
{worker.toString()}
</div>
<button onClick={handleTripleWork}>Triple work!</button>
</>
);
}- After clicking on "Triple work" button 3 times, the result turns out not to be very consistent:
rerender!
Before works 10
After work 1 9
After work 2 9
After work 3 9
rerender!
rerender!
Before works 7
After work 1 6
After work 2 6
After work 3 6
rerender!
Before works 4
After work 1 4
After work 2 4
After work 3 4
rerender!- Although this should be fixed in future releases, be highly aware to mutate your state during rendering.
2.4.2. Working normally with React Strict Mode
- React strict mode is a React feature to prevent you from breaking React's rules, including immutability.
import React from "react"
import { useMustableRegistry } from "@cutetn/react-mustable"
function StrictModeTest() {
const [a, setA] = React.useState<number[]>([]);
const [b, setB] = React.useState<number[]>([]);
const mustableReg = useMustableRegistry();
const c = mustableReg.useMustable(() => new MustableArray<number>(), []);
const handlePush1 = () => {
// immutable approach: proper way to set state
setA((prev) => [...prev, 1]);
// mutable approach: wrong way to set state
setB((prev) => {
prev.push(1);
return prev;
});
// react-mustable approach
c.push(1);
};
return (
<>
<div>a={JSON.stringify(a)}</div>
<div>b={JSON.stringify(b)}</div>
<div>c={JSON.stringify(c.toArray())}</div>
<button onClick={handlePush1}>PUSH 1</button>
</>
);
}- Try rendering the component above under
React.StrictModethen click on the "PUSH 1" button, you will see an unexpected behavior of b. That is because React would execute the set state function twice under Strict Mode, ensuring no mutation is made during this phase. However,react-mustablecompletely dealt with the problem by enforcing the mustable operation to run exactly once. Feel free to turn on Strict Mode during your development!
2.4.3. version field for dependencies list
- Let's say we have a React-mustable instance
worker, and a greeting sentence that should be re-calculate only whenworkerchanges. The regular solution is theReact.useMemohook.
const greetingWorker = React.useMemo(() =>
`Good morning, ${worker.name}, your energy is ${worker.energy}`
, [worker]);- However, in this case, worker is just the same instance with mutable data, which means React itself cannot knowledge changes of
worker, therefore the value ofgreetingWorkerwould never change! - The trick is pretty simple, just use field
versionfromworkeras it is updated whenever a mustable operation is done onworker.
const greetingWorker = React.useMemo(() =>
`Good morning, ${worker.name}, your energy is ${worker.energy}`
, [worker.version]);2.4.4. instance field to work with other logics
- You can get the Mustable instance out of a React-mustable instance from the field
instance. This allows you to do pass the Mustable instance into other functions.Just remember not to mutate any data on the Mustable instance itself as React will not be able to observe the changes.
2.4.5. Updating nested mustable instance
- To mutate a nested member of an Mustable instance, which is another Mustable instance, you must first create a React-mustable wrapper for that instance with the
registerfunction. - For example, this piece of code demonstrates how you can set the name of a worker's manager:
import React from "react"
import { useMustableRegistry } from "@cutetn/react-mustable"
function MyComponent() {
const mustableReg = useMustableRegistry();
const worker = mustableReg.useMustable(() => new Worker("Bob", new Worker("Alice")), []);
const handleChangeManagerName = React.useCallback((e) => {
mustableReg.register(worker.manager!).name = e.target.value;
}, []);
return (
<>
<div style={{ whiteSpace: "pre-wrap", fontFamily: "monospace" }}>{worker.toString()}</div>
<div>
<label>Manager's name: </label>
<input onChange={handleChangeManagerName} value={worker.manager!.name} />
</div>
</>
);
}2.4.6. Async operations are NOT supported
- Despite being considered, async operations are not going to be supported in
react-mustable. That is because mutability could cause many unexpected result in the nature of asynchronous logics. - It is recommended that async operations should be implemented in your components and effects, not in the Mustable classes themselves.