Skip to content

Support immutable Models #52

@jthegedus

Description

@jthegedus

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:

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.

Metadata

Metadata

Assignees

Labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions