-
Notifications
You must be signed in to change notification settings - Fork 0
Description
I have been using Immer to manage my data as immutable structures. It achieves this by using Object.freeze(someObject) internally, with a specific API to mutate these Immer-tracked objects. This is a more robust solution than relying on TypeScript types like readonly as attempted modification to a frozen object throws a JS runtime error (Uncaught TypeError: Cannot assign to read only property of 'value' of object '#<Object>').
I have noticed that the elmish update will replace the Model object and thus remove the frozen object that was previously used. I believe this is the place where this happens:
react-elmish/src/ElmComponent.ts
Lines 115 to 118 in fe4193f
| if (modelHasChanged(this.currentModel, { ...deferredModel, ...model })) { | |
| this.currentModel = { ...this.currentModel, ...deferredModel, ...model }; | |
| modified = true; | |
| } |
It may be possible to achieve what I want with the current APIs, but I cannot figure it out myself if there is.
Reproduction
(click to expand)
// counter.model.ts
import {produce} from "immer";
import type {InitResult,UpdateReturnType} from "react-elmish";
type Message = {name:increment}|{name:decrement};
interface Model = {count:number;}; // using `readonly count: number` only errors in editor or build time thanks to TSC
interface Props = {initialCount: number;};
function init(props: Props): InitResult<Model,Message> {
return [produce({count:props.initialCount}, (draft) => { return draft; })]; // this returns the immutable version of the provided model.
}
function update(mode: Model, msg: Message, props: Props): UpdateReturnType<Model, Message> {
console.log({ updateModelFrozen: Object.isFrozen(model)}); // is always false;
switch(msg.name) {
case "increment": { return [produce(model, (draft) => {draft.count +=1})]; }
case "decrement": { return [produce(model, (draft) => {draft.count -=1})]; }
}
}
export {init, update, Msg, type Model, type Props};
// counter.tsx
import {useElmish} from "react-elmish";
import {type Props, init, update} from "./counter.model.ts";
function Counter(props: Props) {
const [model, dispatch] = useElmish({props,init,update,name:"counter"});
console.log({frozenModel: Object.isFrozen(model)}); // this will initially be true until a Message is dispatched and the update function runs.
return (
<div>
<button type="button" onClick={() => dispatch({name: "increment"})}>+</button>
<button type="button" onClick={() => dispatch({name: "decrement"})}>-</button>
<p>count: {model.count}</p>
<button type="button" onClick={() => {
model.count = 99; // This errors with TSC but is valid JS unless the object is frozen, in which case it throws a runtime error.
}}>
set outside of elmish update
</button>
</div>
);
}
export {Counter};
// App.tsx - use whatever bundler/tooling React index/entrypoint
import {Counter} from "counter";
function App() {
return (
<div>
<Counter initialCount={10} />
</div>
);
}NOTE: I haven't tried to use the Immer produce() function on the UpdateReturnType array tuple, though my assumption would be that it would still produce a new tuple and the Immer object lineage would be lost.
Proposed Solutions
IdeallY I would like to see immutability become the default behaviour. However that would be a big change. A new API (react-elmish/immutable or something) that had an immutable implementation would be ideal. It could either:
- internally use Immer (or similar alternative, or manually implement
Object.freeze()) to enforce immutability of the Model object and provide a "draftable" version of the Model in the Update function. - provide some API for allowing users to specify how they wish to manage the freezing/unfreezing of the Model. Perhaps similar to the logger API.
Each has their pros and cons.
I would opt for a library with immutability by default, especially since those adopting a library like this are trying to get a handle on their data's mutability through the TEA model, but in a non-statically typed environment.
Thanks for the React v19 peerDep update.