A package to work with React Typescript mutable states.
- Introduction
- Installation
- Usage
- License
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".
npm i @cutetn/react-mustable
or
yarn add @cutetn/react-mustable
MustableBase
is an abstract class to interact withreact-mustable
internal logics and React APIs.MustableBase
can be imported directly from the package.
- To utilize
react-mustable
APIs, you must create a class for your state, which extendsMustableBase
class. - You can create any class members you want, including the constructor, methods, fields and properties. The only caveat is you can't access to
version
andinstance
as it is used byreact-mustable
under 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);
}
}
mustable
decorator 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.json
correctly:
{
"compilerOptions": {
"target": "es5",
"experimentalDecorators": true
}
}
Never mind the word "experimental", Angular has been using decorators for ages.
- 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
@mustable
as 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.
- A mustable field is simply a class field decorated with
mustable
. A mustable property is a class property whose eithergetter
orsetter
decorated 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
@mustable
as 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-mustable
system.
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.
}
}
- 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
myFunctionField
will 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 tomyFunctionField
would 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
isMustableFunction
totrue
.
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
myFunctionField
still trigger React to re-render.
- 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
snapshot
function 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 methodmutateField
only mutatethis.obj[fieldName]
, which can be obtained bygetField
. We can actually use the methodgetField
to 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
snapshot
option 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?
- It's sometimes reasonable to have a custom
snapshot
comparer. Using this comparer wisely along withsnapshot
option would benefits the web's performance a lot. - Intuitively, the
sameSnapshotsChecker
receives 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-mustable
provides some basic comparison strategy in thesameSnapshotsCheckers
object.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
sameSnapshotsChecker
option is omitted whilesnapshot
is provided, theisTopLevelArrayShallowSame
would be taken as default strategy.
- The
immustable
decorator does nothing to your members. Nonetheless, it is recommended to use this decorator as a safe checkpoint on animmustable
member, ensuring never forgetting anymustable
members.
react-mustable
packages some built-in Mustable Classes. These are wrappers for JavaScript's built-in data structures, including:MustableArray
,MustableSet
andMustableMap
.- The usage of these classes are almost similar to its original version, with a few key differences:
- Mutable methods return
void
. - An instance of
MustableArray
does 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
- 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.
register
function of the registry object add a wrapper calledReact-Mustable
instance 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
void
regardless 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 returnvoid
to avoid this confusion. - The first parameter of
register
is 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
register
to the same instance would give you the same React-Mustable instance. - To remove a saved React-Mustable wrapper instance, call
remove
function 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 <></>
}
- 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
useMustable
to 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,
useNullableMustable
works almost the same, except that the factory may returnnull
orundefined
; In those cases, the hook would just return the same product as the factory.
- 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
Worker
below:
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
Worker
class:
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.eat
would mutate theworker
instance. The new data immediately reflects to the UI even though it does not look like a regular React'ssetState
. - Voilà, our first working example!
-
React's
setState
does not update state immediately. Instead, the new state is only set once the rendering phase is done. [ref] -
React's queue a series of
setState
for 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.
- 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.StrictMode
then 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-mustable
completely dealt with the problem by enforcing the mustable operation to run exactly once. Feel free to turn on Strict Mode during your development!
- Let's say we have a React-mustable instance
worker
, and a greeting sentence that should be re-calculate only whenworker
changes. The regular solution is theReact.useMemo
hook.
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 ofgreetingWorker
would never change! - The trick is pretty simple, just use field
version
fromworker
as 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]);
- 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.
- 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
register
function. - 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>
</>
);
}
- 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.