Skip to content

Commit 19ac39b

Browse files
authored
Add createModel API for reactive state containers (#812)
## Summary This PR adds `createModel` and `action` to `@preact/signals-core`, providing a structured way to build reactive state containers that encapsulate signals, computed values, effects, and actions. ```js const CounterModel = createModel((initialCount = 0) => { const count = signal(initialCount); const doubled = computed(() => count.value * 2); effect(() => { console.log("Count changed:", count.value); }); return { count, doubled, increment() { count.value++; }, }; }); const counter = new CounterModel(5); counter.increment(); // Updates are automatically batched counter[Symbol.dispose](); // Cleans up all effects ``` Key features: - Factory functions can accept arguments for initialization - All methods are automatically wrapped as actions (batched & untracked) - Effects created during model construction are captured and disposed when the model is disposed via `Symbol.dispose` - Models compose naturally - effects from nested models are captured by the parent and disposed together when the parent is disposed - TypeScript validates that models only contain signals, actions, or nested objects with signals/actions ## Design decisions ### No classes or reflection The implementation avoids using ES classes internally. Using a class would require reflecting onto a class's constructor and the current signals implementation avoids reflection and proxies, so this follows a similar design philosophy. A class-based API could be built on top of this primitive, like so (shoutout @developit for this neat little hack): ```ts class BaseModelImpl implements Disposable { [Symbol.dispose](): void {} } export const BaseModel = new Proxy(BaseModelImpl, { construct(target, args, newTarget) { return createModel(() => Reflect.construct(target, args, newTarget)); }, }) as unknown as typeof BaseModelImpl; ``` ### Using `new` to instantiate models The public types require using `new` to instantiate models. This helps disambiguate the factory function passed into `createModel` from the returned constructor. It's easier to explain that "`createModel` accepts a factory and returns a class" than "`createModel` accepts a factory and returns a factory." In other words, this: ```ts const PersonModel = createModel((name: string) => ({ ... })); const person = new PersonModel("John"); ``` is easier to understand than: ```ts const createPerson = createModel((name: string) => ({ ... })); const person = createPerson("John"); ``` Using `new` also communicates that each call creates a fresh instance with independent state. Internally, `createModel` returns a plain function that can be called without `new` for simplicity, but the public types enforce `new` for clarity. ### Automatically capture effects implement a dispose function Effects declared inside a model's factory function are captured by `createModel` in order to automatically implement a dispose function on the model (exposed as `[Symbol.dispose]`). This design avoids models needing to manually wire up effect dispose from nested models to the model interface. ### Factory functions should NOT return `dispose` functions If a model needs to run custom logic when it is diposed (that may not be related to signals), it should **not** return a `dispose()` or `[Symbol.dispose]`. When composing models, this dispose function isn't guarenteed to get called as parent models would need to know that your model has a dispose and manually wire it up. Instead for custom cleanup logic, the recommended pattern is to declare an effect with no signal dependencies that returns a cleanup function that runs the desired cleanup logic. (see "Dispose pattern" below). ## Recommended patterns ### Explicit readonly pattern Declare your model interface explicitly and use `ReadonlySignal` for signals that should only be modified through actions. This ensures only actions can modify signals, giving you better insight and control over state changes: ```ts import { signal, computed, createModel, ReadonlySignal, } from "@preact/signals-core"; interface Counter { count: ReadonlySignal<number>; doubled: ReadonlySignal<number>; increment(): void; decrement(): void; } const CounterModel = createModel<Counter>(() => { const count = signal(0); const doubled = computed(() => count.value * 2); return { count, doubled, increment() { count.value++; }, decrement() { count.value--; }, }; }); const counter = new CounterModel(); counter.increment(); // OK counter.count.value = 10; // TypeScript error: Cannot assign to 'value' because it is a read-only property ``` ### Dispose pattern Generally, if you delcare an effect that has cleanup logic, that cleanup logic will before each execution of the effect function (aka whenever the signals your effect relies on update). However, if you have cleanup logic that needs to run on model dispose that doesn't depend on signals, define an effect that uses no signals but returns your cleanup function. This mirrors the `useEffect(() => { return cleanup }, [])` pattern in React: ```ts const WebSocketModel = createModel((url: string) => { const messages = signal<string[]>([]); const ws = new WebSocket(url); ws.onmessage = e => { messages.value = [...messages.value, e.data]; }; // This effect runs once and cleanup is called on dispose effect(() => { return () => { ws.close(); }; }); return { messages, send(message: string) { ws.send(message); }, }; }); ``` This pattern is recommended for custom dispose behavior because it allows models to compose naturally - nested models will have their effects cleaned up automatically without manually wiring up dispose functions. ## Future work - Add `useModel` hook to Preact & React adapters - Extend debug transform to add names to models & actions, and use model name in signals, computeds, effects, and actions declared within the model - Extend debug tooling to understand models and actions
1 parent e98c4a5 commit 19ac39b

File tree

9 files changed

+1373
-2
lines changed

9 files changed

+1373
-2
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@preact/signals-core": minor
3+
---
4+
5+
Add `createModel` and `action` to signals core package
6+
7+
**`createModel`** provides a structured way to create reactive model classes that encapsulate signals, computed values, and actions:
8+
9+
```js
10+
const CounterModel = createModel((initialCount = 0) => {
11+
const count = signal(initialCount);
12+
const doubled = computed(() => count.value * 2);
13+
14+
effect(() => {
15+
console.log("Count changed:", count.value);
16+
});
17+
18+
return {
19+
count,
20+
doubled,
21+
increment() {
22+
count.value++;
23+
},
24+
};
25+
});
26+
27+
const counter = new CounterModel(5);
28+
counter.increment(); // Updates are automatically batched
29+
counter[Symbol.dispose](); // Cleans up all effects
30+
```
31+
32+
Key features:
33+
34+
- Factory functions can accept arguments for initialization
35+
- All methods are automatically wrapped as actions (batched & untracked)
36+
- Effects created during model construction are captured and disposed when the model is disposed via `Symbol.dispose`
37+
- TypeScript validates that models only contain signals, actions, or nested objects with signals/actions
38+
39+
**`action`** is a helper that wraps a function to run batched and untracked:
40+
41+
```js
42+
const updateAll = action(items => {
43+
items.forEach(item => item.value++);
44+
}); // All updates batched into single notification
45+
```

docs/demos/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const demos = {
1313
Sum,
1414
GlobalCounter,
1515
DuelingCounters,
16+
Models: lazy(() => import("./todo")),
1617
Devtools: lazy(() => import("./devtools")),
1718
Nesting: lazy(() => import("./nesting")),
1819
Animation: lazy(() => import("./animation")),

docs/demos/render-flasher.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,9 @@ function hasComponentChild(vnode: VNode): boolean {
105105
}
106106

107107
function flash(nodes: (Node | undefined)[], annotation?: string) {
108+
const scrollX = window.scrollX;
109+
const scrollY = window.scrollY;
110+
108111
const range = new Range();
109112
for (let node of nodes) {
110113
if (!node) continue;
@@ -123,8 +126,8 @@ function flash(nodes: (Node | undefined)[], annotation?: string) {
123126
// styled.style.margin = "-2px";
124127
styled.style.background = color;
125128
styled.style.boxShadow = `0 0 3px 3px ${color}`;
126-
styled.style.left = rect.left + "px";
127-
styled.style.top = rect.top + "px";
129+
styled.style.left = rect.left + scrollX + "px";
130+
styled.style.top = rect.top + scrollY + "px";
128131
styled.style.width = rect.width + "px";
129132
styled.style.height = rect.height + "px";
130133
document.documentElement.appendChild(styled);

docs/demos/todo/index.tsx

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
import { useEffect, useState } from "preact/hooks";
2+
import {
3+
createModel,
4+
signal,
5+
computed,
6+
effect,
7+
Model,
8+
ModelConstructor,
9+
ReadonlySignal,
10+
} from "@preact/signals-core";
11+
import { For, Show } from "@preact/signals/utils";
12+
import "./style.css";
13+
14+
interface Todo {
15+
id: number;
16+
text: string;
17+
completed: boolean;
18+
}
19+
20+
let nextId = 0;
21+
22+
interface TodosModel {
23+
todos: ReadonlySignal<Todo[]>;
24+
completedCount: ReadonlySignal<number>;
25+
activeCount: ReadonlySignal<number>;
26+
addTodo: (text: string) => void;
27+
toggleTodo: (id: number) => void;
28+
removeTodo: (id: number) => void;
29+
clearCompleted: () => void;
30+
}
31+
32+
// Core business domain model - manages the todo list and operations
33+
const TodosModel: ModelConstructor<TodosModel> = createModel(() => {
34+
const todos = signal<Todo[]>([], { name: "todos" });
35+
36+
// Computed value: count of completed todos
37+
const completedCount = computed(
38+
() => todos.value.filter(t => t.completed).length,
39+
{ name: "completed-count" }
40+
);
41+
42+
// Computed value: count of active todos
43+
const activeCount = computed(
44+
() => todos.value.filter(t => !t.completed).length,
45+
{ name: "active-count" }
46+
);
47+
48+
// Action: Add a new todo
49+
const addTodo = (text: string) => {
50+
if (text.trim()) {
51+
todos.value = [
52+
...todos.value,
53+
{
54+
id: ++nextId,
55+
text: text.trim(),
56+
completed: false,
57+
},
58+
];
59+
}
60+
};
61+
62+
// Action: Toggle todo completion status
63+
const toggleTodo = (id: number) => {
64+
todos.value = todos.value.map(t =>
65+
t.id === id ? { ...t, completed: !t.completed } : t
66+
);
67+
};
68+
69+
// Action: Remove a todo
70+
const removeTodo = (id: number) => {
71+
todos.value = todos.value.filter(t => t.id !== id);
72+
};
73+
74+
// Action: Clear all completed todos
75+
const clearCompleted = () => {
76+
todos.value = todos.value.filter(t => !t.completed);
77+
};
78+
79+
return {
80+
todos,
81+
completedCount,
82+
activeCount,
83+
addTodo,
84+
toggleTodo,
85+
removeTodo,
86+
clearCompleted,
87+
};
88+
});
89+
90+
interface TodosViewModel {
91+
todosModel: Model<TodosModel>;
92+
filter: ReadonlySignal<"all" | "active" | "completed">;
93+
filteredTodos: ReadonlySignal<Todo[]>;
94+
setFilter: (newFilter: "all" | "active" | "completed") => void;
95+
}
96+
97+
// View model - manages UI state and filtering, composes the business model
98+
const TodosViewModel: ModelConstructor<TodosViewModel> = createModel(() => {
99+
// Nested model: contains the core business logic
100+
const todosModel = new TodosModel();
101+
102+
// View state: current filter
103+
const filter = signal<"all" | "active" | "completed">("all", {
104+
name: "filter",
105+
});
106+
107+
// Computed value: filtered todos based on current filter and todos from nested model
108+
const filteredTodos = computed(
109+
() => {
110+
const currentFilter = filter.value;
111+
const allTodos = todosModel.todos.value;
112+
if (currentFilter === "active") return allTodos.filter(t => !t.completed);
113+
if (currentFilter === "completed")
114+
return allTodos.filter(t => t.completed);
115+
return allTodos;
116+
},
117+
{ name: "filtered-todos" }
118+
);
119+
120+
// Effect: Update document title with active count (view concern)
121+
const originalTitle = document.title;
122+
effect(() => {
123+
const count = todosModel.activeCount.value;
124+
document.title =
125+
count === 0 ? "TodoMVC - No active todos" : `TodoMVC (${count} active)`;
126+
127+
// Cleanup: restore original title when model is disposed
128+
return () => {
129+
document.title = originalTitle;
130+
};
131+
});
132+
133+
// Action: Set the current filter
134+
const setFilter = (newFilter: "all" | "active" | "completed") => {
135+
filter.value = newFilter;
136+
};
137+
138+
const debugData = computed(() => {
139+
return JSON.stringify({todosModel, filter, filteredTodos}, null, 2);
140+
});
141+
142+
return {
143+
// Expose the nested business model
144+
todosModel,
145+
// View-specific state
146+
filter,
147+
filteredTodos,
148+
setFilter,
149+
// For debugging purposes in this demo
150+
debugData,
151+
};
152+
});
153+
154+
function useModel<TModel>(constructModel: () => Model<TModel>): Model<TModel> {
155+
const model = useState(() => constructModel())[0];
156+
useEffect(() => () => model[Symbol.dispose]());
157+
return model;
158+
}
159+
160+
function FilterButton({
161+
filterType,
162+
currentFilter,
163+
onClick,
164+
}: {
165+
filterType: "all" | "active" | "completed";
166+
currentFilter: ReadonlySignal<"all" | "active" | "completed">;
167+
onClick: () => void;
168+
}) {
169+
// Signal reads directly in JSX are reactive - this component only re-renders when currentFilter changes
170+
return (
171+
<button
172+
onClick={onClick}
173+
class={`todo-filter-btn ${currentFilter.value === filterType ? "active" : ""}`}
174+
>
175+
{filterType}
176+
</button>
177+
);
178+
}
179+
180+
export default function TodoMVC() {
181+
// Create a single instance of the view model that persists across renders
182+
const viewModel = useModel(() => new TodosViewModel());
183+
184+
const handleSubmit = (e: Event) => {
185+
e.preventDefault();
186+
const form = e.target as HTMLFormElement;
187+
const input = form.elements.namedItem("todo-input") as HTMLInputElement;
188+
viewModel.todosModel.addTodo(input.value);
189+
input.value = "";
190+
};
191+
192+
return (
193+
<div class="todo-container">
194+
<h1 class="todo-header">TodoMVC Demo</h1>
195+
<p class="todo-description">
196+
Showcasing <code>createModel</code> with separated business and view
197+
models
198+
</p>
199+
200+
{/* Input form */}
201+
<form onSubmit={handleSubmit} class="todo-form">
202+
<input
203+
type="text"
204+
name="todo-input"
205+
placeholder="What needs to be done?"
206+
class="todo-input"
207+
/>
208+
</form>
209+
210+
{/* Todo list */}
211+
<div class="todo-list">
212+
<For each={viewModel.filteredTodos}>
213+
{todo => (
214+
<div key={todo.id} class="todo-item">
215+
<input
216+
type="checkbox"
217+
checked={todo.completed}
218+
onChange={() => viewModel.todosModel.toggleTodo(todo.id)}
219+
class="todo-checkbox"
220+
/>
221+
<span class={`todo-text ${todo.completed ? "completed" : ""}`}>
222+
{todo.text}
223+
</span>
224+
<button
225+
onClick={() => viewModel.todosModel.removeTodo(todo.id)}
226+
class="todo-delete-btn"
227+
>
228+
Delete
229+
</button>
230+
</div>
231+
)}
232+
</For>
233+
</div>
234+
235+
{/* Footer stats and filters */}
236+
<div class="todo-footer">
237+
<div class="todo-footer-stats">
238+
<div class="todo-stats-text">
239+
<strong>{viewModel.todosModel.activeCount}</strong> active,{" "}
240+
<strong>{viewModel.todosModel.completedCount}</strong> completed
241+
</div>
242+
<Show when={viewModel.todosModel.completedCount}>
243+
<button
244+
onClick={() => viewModel.todosModel.clearCompleted()}
245+
class="todo-clear-btn"
246+
>
247+
Clear completed
248+
</button>
249+
</Show>
250+
</div>
251+
252+
{/* Filter buttons */}
253+
<div class="todo-filters">
254+
{(["all", "active", "completed"] as const).map(filterType => (
255+
<FilterButton
256+
key={filterType}
257+
filterType={filterType}
258+
currentFilter={viewModel.filter}
259+
onClick={() => viewModel.setFilter(filterType)}
260+
/>
261+
))}
262+
</div>
263+
</div>
264+
265+
<pre class="todo-debug">{viewModel.debugData}</pre>
266+
</div>
267+
);
268+
}

0 commit comments

Comments
 (0)