Skip to content

Commit 5794b04

Browse files
authored
Add useModel to use a model in a component
## Summary Add `useModel` hook to `@preact/signals` and `@preact/signals-react` packages for using Models (created with `createModel`) within components. The hook handles: - Creating the model instance lazily on first render - Maintaining the same instance across re-renders - Automatically disposing the model when the component unmounts ## Usage ```jsx import { createModel, signal } from "@preact/signals-core"; import { useModel } from "@preact/signals"; // or "@preact/signals-react" const CountModel = createModel(() => ({ count: signal(0), increment() { this.count.value++; }, })); function Counter() { const model = useModel(CountModel); return <button onClick={() => model.increment()}>{model.count}</button>; } ``` For models that require constructor arguments, wrap in a factory function: ```jsx const CountModel = createModel((initialCount: number) => ({ count: signal(initialCount), })); function Counter() { const model = useModel(() => new CountModel(5)); return <div>{model.count}</div>; } ``` ## Changes - Added `useModel` hook to `@preact/signals` (`packages/preact/src/index.ts`) - Added `useModel` hook to `@preact/signals-react` (`packages/react/runtime/src/index.ts`) - Added comprehensive tests for both implementations - Updated todo demo to use the new hook instead of a local implementation ## Test plan - [x] Unit tests for Preact implementation (`packages/preact/test/browser/useModel.test.tsx`) - [x] Unit tests for React implementation (`packages/react/runtime/test/browser/useModel.test.tsx`) - [x] Run `pnpm test` to verify all tests pass - [x] Verify todo demo works correctly with the new hook
1 parent 7e2069d commit 5794b04

9 files changed

Lines changed: 572 additions & 10 deletions

File tree

.changeset/brave-frogs-trade.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
---
2+
"@preact/signals": minor
3+
"@preact/signals-react": minor
4+
---
5+
6+
Add `useModel` hook for using Models in components
7+
8+
The new `useModel` hook provides a convenient way to use Models (created with `createModel`) within React and Preact components. It handles:
9+
10+
- Creating the model instance lazily on first render
11+
- Maintaining the same instance across re-renders
12+
- Automatically disposing the model when the component unmounts
13+
14+
```jsx
15+
import { createModel, signal } from "@preact/signals-core";
16+
import { useModel } from "@preact/signals-react"; // or "@preact/signals"
17+
18+
const CountModel = createModel(() => ({
19+
count: signal(0),
20+
increment() {
21+
this.count.value++;
22+
},
23+
}));
24+
25+
function Counter() {
26+
const model = useModel(CountModel);
27+
return <button onClick={() => model.increment()}>{model.count}</button>;
28+
}
29+
```
30+
31+
For models that require constructor arguments, wrap in a factory function:
32+
33+
```jsx
34+
const CountModel = createModel((initialCount: number) => ({
35+
count: signal(initialCount),
36+
}));
37+
38+
function Counter() {
39+
const model = useModel(() => new CountModel(5));
40+
return <div>{model.count}</div>;
41+
}
42+
```

docs/demos/todo/index.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { useEffect, useState } from "preact/hooks";
21
import {
32
createModel,
43
signal,
@@ -7,7 +6,8 @@ import {
76
Model,
87
ModelConstructor,
98
ReadonlySignal,
10-
} from "@preact/signals-core";
9+
useModel,
10+
} from "@preact/signals";
1111
import { For, Show } from "@preact/signals/utils";
1212
import "./style.css";
1313

@@ -92,6 +92,7 @@ interface TodosViewModel {
9292
filter: ReadonlySignal<"all" | "active" | "completed">;
9393
filteredTodos: ReadonlySignal<Todo[]>;
9494
setFilter: (newFilter: "all" | "active" | "completed") => void;
95+
debugData: ReadonlySignal<string>;
9596
}
9697

9798
// View model - manages UI state and filtering, composes the business model
@@ -151,12 +152,6 @@ const TodosViewModel: ModelConstructor<TodosViewModel> = createModel(() => {
151152
};
152153
});
153154

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-
160155
function FilterButton({
161156
filterType,
162157
currentFilter,

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,8 @@
8484
"prettier": "^3.6.2",
8585
"shx": "^0.3.4",
8686
"typescript": "~5.8.3",
87-
"vitest": "^4.0.17",
88-
"vite": "^6.3.5"
87+
"vite": "^6.3.5",
88+
"vitest": "^4.0.17"
8989
},
9090
"lint-staged": {
9191
"**/*.{js,mjs,jsx,ts,tsx,yml,yaml,json,md,html,css}": [

packages/core/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -971,6 +971,9 @@ export type ModelConstructor<TModel, TFactoryArgs extends any[] = []> = new (
971971
* this internal interface that extends the public interface but also
972972
* allows calling without `new`.
973973
*
974+
* This pattern is used by the Preact & React adapters to make instantiating
975+
* a model or a function that returns a model easier.
976+
*
974977
* @internal
975978
*/
976979
interface InternalModelConstructor<TModel, TFactoryArgs extends any[]>

packages/preact/src/index.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -491,6 +491,24 @@ export function useSignalEffect(
491491
}, []);
492492
}
493493

494+
/** See comment in packages/core/src/index.ts on the same interface for an explanation */
495+
interface InternalModelConstructor<TModel, TArgs extends any[]>
496+
extends ModelConstructor<TModel, TArgs> {
497+
(...args: TArgs): Model<TModel>;
498+
}
499+
500+
export function useModel<TModel>(
501+
factory: ModelConstructor<TModel, []> | (() => Model<TModel>)
502+
): Model<TModel> {
503+
type InternalFactory =
504+
| InternalModelConstructor<TModel, []>
505+
| (() => Model<TModel>);
506+
507+
const [inst] = useState(() => (factory as InternalFactory)());
508+
useEffect(() => inst[Symbol.dispose], [inst]);
509+
return inst;
510+
}
511+
494512
/**
495513
* @todo Determine which Reactive implementation we'll be using.
496514
* @internal
Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
import { createModel, signal, useModel } from "@preact/signals";
2+
import { createElement, render, Fragment } from "preact";
3+
import { act } from "preact/test-utils";
4+
import {
5+
describe,
6+
it,
7+
expect,
8+
MockInstance,
9+
vi,
10+
beforeEach,
11+
afterEach,
12+
} from "vitest";
13+
14+
describe("useModel", () => {
15+
let scratch: HTMLDivElement;
16+
17+
beforeEach(() => {
18+
scratch = document.createElement("div");
19+
});
20+
21+
afterEach(() => {
22+
render(null, scratch);
23+
});
24+
25+
it("creates model instance using model constructor", () => {
26+
const CountModel = createModel(() => ({
27+
count: signal(0),
28+
increment() {
29+
this.count.value++;
30+
},
31+
}));
32+
33+
function Counter() {
34+
const model = useModel(CountModel);
35+
return <button onClick={() => model.increment()}>{model.count}</button>;
36+
}
37+
38+
render(<Counter />, scratch);
39+
const button = scratch.querySelector("button")!;
40+
41+
expect(button.textContent).toBe("0");
42+
43+
act(() => button.click());
44+
45+
expect(button.textContent).toBe("1");
46+
});
47+
48+
it("creates model instance using wrapper around model constructor", () => {
49+
const CountModel = createModel(() => ({
50+
count: signal(0),
51+
increment() {
52+
this.count.value++;
53+
},
54+
}));
55+
56+
function Counter() {
57+
const model = useModel(() => new CountModel());
58+
return <button onClick={() => model.increment()}>{model.count}</button>;
59+
}
60+
61+
render(<Counter />, scratch);
62+
const button = scratch.querySelector("button")!;
63+
64+
expect(button.textContent).toBe("0");
65+
66+
act(() => button.click());
67+
68+
expect(button.textContent).toBe("1");
69+
});
70+
71+
it("creates model instance using wrapper around model constructor with arguments", () => {
72+
const CountModel = createModel((initialCount: number) => ({
73+
count: signal(initialCount),
74+
increment() {
75+
this.count.value++;
76+
},
77+
}));
78+
79+
function Counter() {
80+
const model = useModel(() => new CountModel(5));
81+
return <button onClick={() => model.increment()}>{model.count}</button>;
82+
}
83+
84+
render(<Counter />, scratch);
85+
const button = scratch.querySelector("button")!;
86+
87+
expect(button.textContent).toBe("5");
88+
89+
act(() => button.click());
90+
91+
expect(button.textContent).toBe("6");
92+
});
93+
94+
it("returns the same instance across multiple renders", () => {
95+
const CountModel = createModel(() => ({
96+
count: signal(0),
97+
increment() {
98+
this.count.value++;
99+
},
100+
}));
101+
102+
let modelInstances: any[] = [];
103+
104+
function Counter() {
105+
const model = useModel(() => new CountModel());
106+
modelInstances.push(model);
107+
return (
108+
<button onClick={() => model.increment()}>{model.count.value}</button>
109+
);
110+
}
111+
112+
render(<Counter />, scratch);
113+
const button = scratch.querySelector("button")!;
114+
115+
expect(button.textContent).toBe("0");
116+
expect(modelInstances.length).toBe(1);
117+
118+
act(() => button.click());
119+
120+
expect(button.textContent).toBe("1");
121+
expect(modelInstances.length).toBe(2);
122+
expect(modelInstances[0]).toBe(modelInstances[1]);
123+
});
124+
125+
it("diposes the model on unmount", () => {
126+
const CountModel = createModel(() => ({ count: signal(0) }));
127+
128+
let disposeSpy: MockInstance | undefined;
129+
function Counter() {
130+
const model = useModel(CountModel);
131+
disposeSpy = vi.spyOn(model, Symbol.dispose);
132+
return <div>{model.count}</div>;
133+
}
134+
135+
act(() => render(<Counter />, scratch));
136+
expect(disposeSpy).not.toHaveBeenCalled();
137+
138+
act(() => render(null, scratch));
139+
expect(disposeSpy).toHaveBeenCalledTimes(1);
140+
});
141+
142+
it("disposes the model on unmount when created via a factory function", () => {
143+
const CountModel = createModel(() => ({ count: signal(0) }));
144+
145+
let disposeSpy: MockInstance | undefined;
146+
function Counter() {
147+
const model = useModel(() => new CountModel());
148+
disposeSpy = vi.spyOn(model, Symbol.dispose);
149+
return <div>{model.count}</div>;
150+
}
151+
152+
act(() => render(<Counter />, scratch));
153+
expect(disposeSpy).not.toHaveBeenCalled();
154+
155+
act(() => render(null, scratch));
156+
expect(disposeSpy).toHaveBeenCalledTimes(1);
157+
});
158+
159+
it("ignores changing the factory function between renders", () => {
160+
const CountModel = createModel(() => ({
161+
count: signal(0),
162+
increment() {
163+
this.count.value++;
164+
},
165+
}));
166+
167+
let modelInstances: any[] = [];
168+
let useAlternateFactory = false;
169+
170+
function Counter() {
171+
const factory = useAlternateFactory ? () => new CountModel() : CountModel;
172+
const model = useModel(factory);
173+
modelInstances.push(model);
174+
return (
175+
<button onClick={() => model.increment()}>{model.count.value}</button>
176+
);
177+
}
178+
179+
render(<Counter />, scratch);
180+
const button = scratch.querySelector("button")!;
181+
182+
expect(button.textContent).toBe("0");
183+
expect(modelInstances.length).toBe(1);
184+
185+
act(() => {
186+
useAlternateFactory = true;
187+
button.click();
188+
});
189+
190+
expect(button.textContent).toBe("1");
191+
expect(modelInstances.length).toBe(2);
192+
expect(modelInstances[0]).toBe(modelInstances[1]);
193+
});
194+
195+
describe("Typescript Types", () => {
196+
it("fail when using useModel with incompatible model constructor", () => {
197+
function SimpleClass() {
198+
// @ts-expect-error Should be a ModelConstructor
199+
expect(() => useModel(class {})).toThrow();
200+
return null;
201+
}
202+
203+
function SimpleFunction() {
204+
// @ts-expect-error Factory should return a Model Constructor
205+
useModel(() => ({}));
206+
return null;
207+
}
208+
209+
const ModelWithArgs = createModel((arg: number) => ({
210+
value: signal(arg),
211+
}));
212+
213+
function WithArgs() {
214+
// @ts-expect-error useModel cannot instantiate a model constructor with arguments
215+
useModel(ModelWithArgs);
216+
// Correct usage is to wrap in a factory function
217+
useModel(() => new ModelWithArgs(0));
218+
return null;
219+
}
220+
221+
render(
222+
<>
223+
<SimpleClass />
224+
<SimpleFunction />
225+
<WithArgs />
226+
</>,
227+
scratch
228+
);
229+
});
230+
});
231+
});

packages/react/runtime/src/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import {
66
ReadonlySignal,
77
SignalOptions,
88
EffectOptions,
9+
type Model,
10+
type ModelConstructor,
911
} from "@preact/signals-core";
1012
import {
13+
useState,
1114
useRef,
1215
useMemo,
1316
useEffect,
@@ -441,3 +444,21 @@ declare global {
441444
__PREACT_SIGNALS_DEVTOOLS__: SignalsDevToolsAPI;
442445
}
443446
}
447+
448+
/** See comment in packages/core/src/index.ts on the same interface for an explanation */
449+
interface InternalModelConstructor<TModel, TArgs extends any[]>
450+
extends ModelConstructor<TModel, TArgs> {
451+
(...args: TArgs): Model<TModel>;
452+
}
453+
454+
export function useModel<TModel>(
455+
factory: ModelConstructor<TModel, []> | (() => Model<TModel>)
456+
): Model<TModel> {
457+
type InternalFactory =
458+
| InternalModelConstructor<TModel, []>
459+
| (() => Model<TModel>);
460+
461+
const [inst] = useState(() => (factory as InternalFactory)());
462+
useEffect(() => inst[Symbol.dispose], [inst]);
463+
return inst;
464+
}

0 commit comments

Comments
 (0)