@oimdb/react 2.1.2 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,182 +1,322 @@
1
1
  # @oimdb/react
2
2
 
3
- React hooks and context helpers for OIMDB reactive collections, objects, and indexes.
3
+ React bindings for `@oimdb/core`. There is one binding hook and a handful of wrappers around it:
4
+ two that save you from writing `useMemo` at every call site, three that resolve a key and own
5
+ that lifetime for you, and two that own a computed store slot for the component. One more hook
6
+ owns a lifetime and takes no subscription at all, for the derivations a component reads.
4
7
 
5
- 📖 **[Full documentation](https://abaikov.github.io/oimdb/)**
6
-
7
- ## For AI Assistants
8
-
9
- If you are an AI assistant, read the full API reference before writing any OIMDB code:
10
-
11
- ```
12
- node_modules/@oimdb/core/llms.txt
8
+ ```bash
9
+ npm install @oimdb/react @oimdb/core
13
10
  ```
14
11
 
15
- CDN fallback: `https://cdn.jsdelivr.net/npm/@oimdb/core/llms.txt`
12
+ ## Why one binding
16
13
 
17
- ## Installation
14
+ Subscribing in this engine is one call: `OIMEffectService.INSTANCE.subscribeEffect(db, sourceSlots, sourceComputeds, body)`.
15
+ It carries no value; what changed is read off the slots afterwards. So a component needs
16
+ exactly three things: the roots a write can reach, the derivations to ask before reading,
17
+ and the derive function. Together they make up `TOIMBinding`, and `useOIMValue` is the hook that applies it through
18
+ `useSyncExternalStore`.
18
19
 
19
- ```bash
20
- npm install @oimdb/react @oimdb/core
20
+ ```ts signature
21
+ type TOIMBinding<TValue> = {
22
+ readonly sourceSlots?: readonly TOIMSlot[];
23
+ readonly sourceComputeds?: readonly TOIMComputedSlot[];
24
+ readonly derive: () => TValue;
25
+ };
21
26
  ```
22
27
 
23
- ## Model
28
+ Both source keys are optional; a missing one means none.
24
29
 
25
- Collections own entities. Indexes, command streams, and other derived state live next to collections in your application schema.
30
+ ## A complete example
26
31
 
27
- ```typescript
28
- import {
29
- OIMEventQueue,
30
- OIMReactiveCollection,
31
- OIMReactiveCollectionIndexManualSetBased,
32
- } from '@oimdb/core';
32
+ The store is created once, outside React, and shared by every example on this page.
33
+ A service is built once per type, at module scope, and holds no data; `createStoreSlot(db)` makes one
34
+ instance, a store slot, and every store door of the service takes that store slot first.
35
+ Whatever a read mints is swept at the end of the frame unless something took a real reference to it.
33
36
 
34
- type User = {
35
- id: string;
36
- teamId: string;
37
- name: string;
38
- };
37
+ ```ts setup
38
+ import { OIMDB, createOIMBucketService, createOIMEntityService, type TOIMEntitySlot } from '@oimdb/core';
39
39
 
40
- const queue = new OIMEventQueue();
40
+ type TCard = { id: string; deckId: string; title: string; rank: number };
41
41
 
42
- const users = new OIMReactiveCollection<User, string>(queue, {
43
- selectPk: (user) => user.id,
44
- });
42
+ export const db = new OIMDB();
45
43
 
46
- const indexes = {
47
- usersByTeam: new OIMReactiveCollectionIndexManualSetBased<
48
- string,
49
- string,
50
- User
51
- >(queue, { collection: users }),
52
- };
53
- ```
44
+ export const cardService = createOIMEntityService<TCard>(db, { keyOf: (card) => card.id });
45
+ export const cards = cardService.createStoreSlot(db);
54
46
 
55
- The React context stores collections only:
47
+ // A bucket keeps the order the caller writes; a sorted list is
48
+ // `createOIMComputedServiceSorted` over an index.
49
+ export const byDeckService = createOIMBucketService<TOIMEntitySlot<TCard>>(db);
50
+ export const byDeck = byDeckService.createStoreSlot(db);
56
51
 
57
- ```typescript
58
- import { OIMCollectionsProvider } from '@oimdb/react';
52
+ // A BUCKET'S SLOTS ARE YOURS. A write moves the card's value and files nothing, so the
53
+ // code that writes a deck also says who is in it. `byDeckService.holdSlotByKey` is the door onto a
54
+ // bucket by key: it mints one if there is none and hands back the bucket itself,
55
+ // counted. `owner.unholdSlot` gives that count back, once the bucket's own slots keep it.
56
+ export function setDeck(deckId: string, cardsInDeck: readonly TCard[]): void {
57
+ cardService.slotContentService.upsertEntities(cards, cardsInDeck);
58
+ const bucket = byDeckService.holdSlotByKey(byDeck, deckId);
59
+ byDeckService.slotContentService.writeBucketMemberSlots(
60
+ bucket, cardsInDeck.map((card) => cardService.getSlotByKey(cards, card.id)),
61
+ );
62
+ bucket.owner.unholdSlot(bucket);
63
+ }
64
+ ```
59
65
 
60
- const collections = { users };
66
+ ```tsx
67
+ import { OIMDBProvider, useOIMEntity, useOIMBucketSlotsByKey } from '@oimdb/react';
61
68
 
62
69
  function App() {
63
- return (
64
- <OIMCollectionsProvider collections={collections}>
65
- <UsersList />
66
- </OIMCollectionsProvider>
67
- );
70
+ return (
71
+ <OIMDBProvider db={db}>
72
+ <Card pk="c1" />
73
+ <DeckList deckId="d1" />
74
+ </OIMDBProvider>
75
+ );
68
76
  }
69
- ```
70
77
 
71
- Keep indexes in your module, app schema, props, or a separate context:
72
-
73
- ```typescript
74
- import {
75
- useOIMCollectionsContext,
76
- useSelectEntitiesByIndexKeySetBased,
77
- } from '@oimdb/react';
78
+ // One entity, by key.
79
+ function Card({ pk }: { pk: string }) {
80
+ const card = useOIMEntity(cardService, cards, pk);
81
+ return <span>{card?.title}</span>;
82
+ }
78
83
 
79
- type AppCollections = typeof collections;
84
+ // One group, by key. The hook resolves it, holds it while this component is
85
+ // mounted, and gives the hold back when it unmounts or when `deckId` moves —
86
+ // there is no release to remember here.
87
+ function DeckList({ deckId }: { deckId: string }) {
88
+ const deckCards = useOIMBucketSlotsByKey(byDeckService, byDeck, deckId);
89
+ return <ul>{deckCards.map((s) => <li key={String(s.key)}>{s.entity?.title}</li>)}</ul>;
90
+ }
91
+ ```
80
92
 
81
- function TeamUsers({ teamId }: { teamId: string }) {
82
- const { users } = useOIMCollectionsContext<AppCollections>();
93
+ ## The provider
94
+
95
+ **`<OIMDBProvider db={db}>`** puts the database in context (`OIMDBContext`), and every hook except
96
+ `useOIMSlotsByKeys` reads it through `useOIMDB()`. The effect service is stateless
97
+ (`OIMEffectService.INSTANCE`) and takes the database as its channel map, so there is nothing else
98
+ to provide. A slot filed on another `OIMDB` than the provider's would never wake the component, so
99
+ the hooks that resolve or take a slot check that its store slot runs on `db.defaultChannelSlot` and
100
+ throw when it does not.
101
+
102
+ ## The hooks
103
+
104
+ Every hook that reads a collection takes the collection service first and the store slot second.
105
+ The parameter is typed as a `Pick` of `OIMSlotCollectionService` with only the members the hook calls.
106
+
107
+ - **`useOIMValue(binding)`**: the binding. It subscribes one effect over the binding's roots and
108
+ derivations (the effect holds each of them for as long as it lives), settles the derivations before
109
+ every read, and re-renders when the effect fires. **The binding must keep its identity across renders.** See
110
+ [the requirement](#the-binding-you-hand-useoimvalue-must-keep-its-identity); breaking it
111
+ causes a hang, not a slow path.
112
+ - **`useOIMComputedValue(computedSlot)`**: the value of ONE computed slot, re-rendering
113
+ when it changes. It is `useOIMValue` over `sourceComputeds: [computedSlot]`, with the
114
+ value read off `computedSlot.value` after `useOIMValue` settles it.
115
+ - **`useOIMEntity(entityCollectionService, entityStoreSlot, key)`**: one entity, by key. The key is resolved
116
+ to an identity ON COMMIT, not on render, through `holdSlotByKey(entityStoreSlot, key)`: a render is not a
117
+ lifetime, and an identity a discarded render minted would have no hold to give back. The render path reads
118
+ `getSlotByKey(entityStoreSlot, key)`, so the first paint shows current state. That read mints when the key
119
+ has none, but the mint goes to the database's unused-slot collector, so a render that never commits leaves
120
+ nothing standing past the next `db.flush()`.
121
+ - **`useOIMField(entityCollectionService, slot, name)`**: one field of one identity, through an
122
+ effect on `entityCollectionService.getColumn(slot, name)`. `name` is a column of that slot's entity, so a typo is a
123
+ compile error and the value comes back with the type that column holds. It re-renders when
124
+ that field changes and for no other reason. `slot` is an ENTITY slot: the subscription holds the identity it
125
+ did not resolve.
126
+ - **`useOIMBucketSlots(bucketCollectionService, bucketSlot)`**: a bucket's slots as a readonly array, in that
127
+ bucket's order, read through `bucketCollectionService.slotContentService.getBucketMemberSlots`. The array is
128
+ copied only when the bucket slot's change stamp (`changedAtRevision`, `changedAtEpoch`) moved. That stamp is
129
+ reliable here because the caller holds the bucket: a write to a slot nothing holds is not stamped.
130
+ - **`useOIMBucketSlotsByKey(bucketCollectionService, bucketStoreSlot, key)`**: one bucket by key, as an array of
131
+ entity slots. It resolves the bucket on commit, holds it for as long as the component is mounted, and gives the
132
+ hold back on unmount or when the key changes. It copies the members on every render: the bucket it reads during a
133
+ render may be held by nothing yet, and an unheld bucket's stamp does not move. **The key may be built inline**,
134
+ including the array that keys an `OIMSlotStoreServiceTreeDriven`; the hook settles it by asking the store which
135
+ bucket each key names.
136
+ - **`useOIMSlotsByKeys(slotCollectionService, storeSlot, keys)`**: several identities of one store slot,
137
+ resolved by key and held for as long as the component is mounted. It returns the slots and takes no subscription,
138
+ so use it when a component reads **derivations**: a computed slot exists only while something holds it, and a bare
139
+ read mints one that the next `flush()` takes straight back. See [Holds per component](#holds-per-component).
140
+ - **`useOIMComputedValueByKey(computedCollectionService, computedStoreSlot, key)`**: the value of one key of a
141
+ computed store slot. Pass `computedService.computedCollectionService`. It has the same body as `useOIMEntity`:
142
+ it resolves with `holdSlotByKey` on commit, subscribes over that computed slot, gives the resolve-hold straight
143
+ back, and reads the value through `computedSlot.computedValueService.getValueForSlot`.
144
+ - **`useOIMComputedCollection(computedService, derive, createSources, deps)`**: a keyed computed store slot the
145
+ component owns. The SERVICE is yours, built once at module scope with `createOIMComputedService(options)`; the hook
146
+ only calls `computedService.createComputedStoreSlot(db, derive, createSources)` in a `useMemo` keyed on `deps`, and
147
+ returns that store slot. `derive` and `createSources` may be `undefined` when the service already has them.
148
+ Read it by key with `useOIMComputedValueByKey(computedService.computedCollectionService, computedStoreSlot, key)`.
149
+ - **`useOIMSingleComputedValue(computedService, derive, createSources, deps)`**: a single computed the component
150
+ owns, read under the key `undefined`. The service is a void-keyed `createOIMComputedService` with
151
+ `computedSlotStoreService: OIMSlotStoreServiceSingleSlotDriven.INSTANCE`, built once at module scope. Per mount
152
+ the hook builds a store slot through `useOIMComputedCollection` and reads it through `useOIMComputedValueByKey`,
153
+ so a flush between render and subscribe, which releases the slot the render minted, is safe.
154
+ - **`useOIMSingleEntity(singleSlotService, singleSlotStoreSlot)`** and
155
+ **`useOIMSingleBucketSlots(singleSlotService, singleSlotStoreSlot)`**: the single entity and the single bucket.
156
+ See [Singles](#singles).
157
+ - **`useOIMDB()`**: the `OIMDB` from `OIMDBContext`. It throws if used outside `<OIMDBProvider>`.
83
158
 
84
- const teamUsers = useSelectEntitiesByIndexKeySetBased(
85
- users,
86
- indexes.usersByTeam,
87
- teamId
88
- );
159
+ ```tsx
160
+ import { OIMSlotStoreServiceSingleSlotDriven, createOIMComputedService } from '@oimdb/core';
161
+ import type { TOIMComputedSlot, TOIMSingleSlotStore } from '@oimdb/core';
162
+ import { useOIMSingleComputedValue } from '@oimdb/react';
163
+
164
+ // Module scope: one service per kind of computed, shared by every component.
165
+ const totalRankService = createOIMComputedService<void, number, TOIMSingleSlotStore<TOIMComputedSlot<number, void>>>({
166
+ computedSlotStoreService:
167
+ OIMSlotStoreServiceSingleSlotDriven.INSTANCE as OIMSlotStoreServiceSingleSlotDriven<TOIMComputedSlot<number, void>>,
168
+ });
89
169
 
90
- return (
91
- <ul>
92
- {teamUsers?.map((user) =>
93
- user ? <li key={user.id}>{user.name}</li> : null
94
- )}
95
- </ul>
96
- );
170
+ function TotalRank({ ids }: { ids: readonly string[] }) {
171
+ // Per mount: one store slot with its own derive and sources.
172
+ const total = useOIMSingleComputedValue(
173
+ totalRankService,
174
+ () => ids.reduce((sum, id) => sum + (cardService.getSlotByKey(cards, id).entity?.rank ?? 0), 0),
175
+ () => ({ sourceSlots: ids.map((id) => cardService.getSlotByKey(cards, id)) }),
176
+ [ids.join()],
177
+ );
178
+ return <span>{total}</span>;
97
179
  }
98
180
  ```
99
181
 
100
- ## Hooks
182
+ ## Singles
101
183
 
102
- ### Collections
184
+ A single is a store slot that holds ONE identity and has no key: `createOIMSingleEntityService(db)` and
185
+ `createOIMSingleBucketService(db)` hand back an `OIMSingleSlotService`, read through `getSingleSlot(storeSlot)` and
186
+ `holdSingleSlot(storeSlot)`. The two single hooks take that service (typed as a `Pick` of `OIMSingleSlotService`)
187
+ and its store slot, and have the by-key hooks' mechanics without the key: the render reads `getSingleSlot`, whose
188
+ mint the next `flush()` takes back if no commit follows; the commit resolves with `holdSingleSlot`, subscribes over
189
+ that slot and gives the resolve-hold straight back, so a single released while nothing watched it is minted afresh.
103
190
 
104
- - `useSelectEntityByPk(collection, pk)` watches one entity.
105
- - `useSelectEntitiesByPks(collection, pks)` watches multiple entities.
191
+ ```tsx
192
+ import { createOIMSingleBucketService, createOIMSingleEntityService } from '@oimdb/core';
193
+ import { useOIMSingleBucketSlots, useOIMSingleEntity } from '@oimdb/react';
106
194
 
107
- ### Set-Based Indexes
195
+ type TSettings = { theme: string };
108
196
 
109
- - `useSelectPksByIndexKeySetBased(index, key)` watches PKs for one index key.
110
- - `useSelectPksByIndexKeysSetBased(index, keys)` watches PKs for several index keys.
111
- - `useSelectEntitiesByIndexKeySetBased(collection, index, key)` watches entities referenced by one index key.
112
- - `useSelectEntitiesByIndexKeysSetBased(collection, index, keys)` watches entities referenced by several index keys.
197
+ const settingsService = createOIMSingleEntityService<TSettings>(db);
198
+ const settings = settingsService.createStoreSlot(db);
113
199
 
114
- ### Array-Based Indexes
200
+ const pickedService = createOIMSingleBucketService<TOIMEntitySlot<TCard>>(db);
201
+ const picked = pickedService.createStoreSlot(db);
115
202
 
116
- - `useSelectPksByIndexKeyArrayBased(index, key)` watches PKs for one index key.
117
- - `useSelectPksByIndexKeysArrayBased(index, keys)` watches PKs for several index keys.
118
- - `useSelectEntitiesByIndexKeyArrayBased(collection, index, key)` watches entities referenced by one index key.
119
- - `useSelectEntitiesByIndexKeysArrayBased(collection, index, keys)` watches entities referenced by several index keys.
203
+ function Toolbar() {
204
+ const theme = useOIMSingleEntity(settingsService, settings)?.theme;
205
+ const pickedCards = useOIMSingleBucketSlots(pickedService, picked);
206
+ return <span>{theme}: {pickedCards.length} picked</span>;
207
+ }
208
+ ```
120
209
 
121
- ### Reactive Objects
210
+ `useOIMSingleBucketSlots` copies the members on every render, for the same reason as `useOIMBucketSlotsByKey`.
211
+ A single computed is `useOIMSingleComputedValue`.
122
212
 
123
- - `useSelectValueByObjectKey(object, key)` watches one object key.
124
- - `useSelectValuesByObjectKeys(object, keys)` watches several object keys.
213
+ ## Binding a derivation
125
214
 
126
- All entity/index hooks return holes as `undefined` (`(TEntity | undefined)[]`), aligned 1:1 with the requested pks/keys — render with a guard (`user ? … : null`).
215
+ `createOIMComputedService` and `createOIMComputedServiceBucketMemo` both hand back a service of computed slots,
216
+ whose store slot holds them. You can either hold the key where the lifetime is (a module, your own store) and bind
217
+ it with `useOIMValue`, or let the component own the lifetime with `useOIMSlotsByKeys`.
127
218
 
128
- ## Mutable mode + signal hooks (advanced)
219
+ `useOIMValue` produces the binding's derivations before every read, because a derivation is
220
+ asked and never woken. Only a computed slot's `computedValueService.getValueForSlot` produces its value, so a
221
+ `derive` that reaches a derivation's result any other way would show whatever the last produce left there.
129
222
 
130
- The default hooks use `useSyncExternalStore`, which detects change by **reference** (`Object.is`). That requires the store to produce a new entity object per update — the default immutable merge (`{ ...prev, ...draft }`), whose copy is the biggest per-update data-layer cost.
223
+ **No wake is owed for what happened before the subscription.** An effect is stamped with the channel clock at
224
+ birth (`ranAtRevision` / `ranAtEpoch`), so a frame runs the body only when a source moved *after* the subscription.
225
+ Every hook here subscribes with `subscribeEffect`; `OIMEffectService.INSTANCE.createEffectImmediate` is the door
226
+ that delivers a value at once.
131
227
 
132
- For update-heavy, fine-grained UIs you can run a collection in **in-place / mutable** mode and bind it with the lighter **signal hooks**:
228
+ ## Holds per component
229
+
230
+ `useOIMSlotsByKeys` holds every identity its keys name for as long as the component is mounted: on commit it
231
+ resolves each key through `holdSlotByKey(storeSlot, key)` and keeps that hold, and on unmount (or when the key list
232
+ moves) it gives back exactly the holds that commit took.
133
233
 
134
234
  ```tsx
135
- import { OIMReactiveCollection, createInPlaceEntityUpdater } from '@oimdb/core';
136
- import {
137
- useSelectEntityByPkSignal,
138
- useSelectPksByIndexKeyArrayBasedSignal,
139
- useSelectPksByIndexKeySetBasedSignal,
140
- } from '@oimdb/react';
141
-
142
- // Mutate entities in place — no per-update object allocation.
143
- const cards = new OIMReactiveCollection<Card, string>(queue, {
144
- selectPk: (c) => c.id,
145
- updateEntity: createInPlaceEntityUpdater(),
235
+ import { createOIMComputedService, oimSameValueZeroGuard } from '@oimdb/core';
236
+ import { useOIMSlotsByKeys } from '@oimdb/react';
237
+
238
+ const rankService = createOIMComputedService<string, number>({
239
+ createSources: (id) => ({ sourceSlots: [cardService.getSlotByKey(cards, id)] }),
240
+ derive: (computedSlot) => cardService.getSlotByKey(cards, computedSlot.key).entity?.rank ?? -1,
241
+ sameValueGuard: oimSameValueZeroGuard,
146
242
  });
243
+ const ranks = rankService.createComputedStoreSlot(db);
147
244
 
148
- // Signal hooks re-render on the keyed notification (no Object.is), so they see
149
- // in-place mutations the default uSES hooks would miss.
150
- const card = useSelectEntityByPkSignal(cards, id);
151
- const ids = useSelectPksByIndexKeyArrayBasedSignal(cardsByDeck, deckId);
245
+ function Hand({ ids }: { ids: readonly string[] }) {
246
+ const slots = useOIMSlotsByKeys(rankService.computedCollectionService, ranks, ids);
247
+ return <ul>{slots.map((s) => <li key={String(s.key)}>{s.computedValueService.getValueForSlot(s)}</li>)}</ul>;
248
+ }
152
249
  ```
153
250
 
154
- Available signal hooks (same signatures as their default counterparts): `useSelectEntityByPkSignal`, `useSelectPksByIndexKeyArrayBasedSignal`, `useSelectPksByIndexKeySetBasedSignal`. Compose the fine-grained pattern from them: a parent reads pks by index key, each row reads its own entity by pk.
155
-
156
- **Use only when every reader is subscription-driven.** Trade-offs: not Concurrent-Mode tearing-safe; the entity reference is stable across changes, so `React.memo` on entities, prev/next diffing, time-travel, and the default uSES hooks on the same collection won't see updates. Select each entity where you render it (by pk); don't pass mutable entities into `React.memo` children. See [Performance](https://abaikov.github.io/oimdb/docs/guides/performance) for when this actually pays off (in plain React the per-component commit usually dominates).
251
+ - **It resolves twice, on purpose.** The render goes through `getSlotByKey(storeSlot, key)`, whose mint the
252
+ database's unused-slot collector takes back at the next `flush()`, so a render that React throws away leaks
253
+ nothing. The commit then resolves the same keys through `holdSlotByKey(storeSlot, key)`. If a `flush()` landed
254
+ between the two and the commit therefore resolved a *different* identity, the hook re-renders once so nothing
255
+ downstream is left holding a released slot.
256
+ - **The key list may be built inline.** It is settled by contents, so `ids={['a', 'b']}` costs a length check and
257
+ two `===` on a render that did not change it, and takes no new holds.
258
+ - **It owns a lifetime, not a subscription.** Nothing here re-renders when a derivation's value moves; `useOIMValue`
259
+ does that. Hold with this hook, wake with that one. It needs no provider because it subscribes to nothing.
157
260
 
158
- ## Context API
261
+ ## The binding you hand `useOIMValue` must keep its identity
159
262
 
160
- ```typescript
161
- import {
162
- OIMCollectionsProvider,
163
- createOIMCollectionsContext,
164
- useOIMCollectionsContext,
165
- } from '@oimdb/react';
166
- ```
263
+ This is a hard requirement. Getting it wrong costs more than an allocation: it hangs the screen.
167
264
 
168
- `OIMCollectionsProvider` accepts a dictionary of `OIMReactiveCollection` instances. Use `typeof collections` to preserve exact collection types:
265
+ ```tsx
266
+ import { useMemo } from 'react';
267
+ import { useOIMValue } from '@oimdb/react';
169
268
 
170
- ```typescript
171
- const collections = {
172
- users,
173
- };
269
+ function Deck({ bucket }: { bucket: ReturnType<typeof byDeckService.getSlotByKey> }) {
270
+ // DO NOT. A new binding every render, and this never stops rendering.
271
+ const unstable = useOIMValue({ sourceSlots: [bucket],
272
+ derive: () => Array.from(byDeckService.slotContentService.getBucketMemberSlots(bucket)) });
174
273
 
175
- type AppCollections = typeof collections;
274
+ // Do. The binding is memoised on what it was built from.
275
+ const stable = useOIMValue(useMemo(() => ({
276
+ sourceSlots: [bucket],
277
+ derive: () => Array.from(byDeckService.slotContentService.getBucketMemberSlots(bucket)),
278
+ }), [bucket]));
176
279
 
177
- function UserName({ userId }: { userId: string }) {
178
- const { users } = useOIMCollectionsContext<AppCollections>();
179
- const user = useSelectEntityByPk(users, userId);
180
- return <span>{user?.name}</span>;
280
+ return <span>{unstable.length + stable.length}</span>;
181
281
  }
182
282
  ```
283
+
284
+ The first form misses `useOIMValue`'s memo on every render, which gives `subscribe` a new
285
+ identity, and React responds by resubscribing on every commit. The counter bump on
286
+ `subscribe`'s first line then makes React's post-commit version check differ, so React forces another
287
+ render, which builds another binding. **Nothing converges.** React logs
288
+ `Maximum update depth exceeded`, which is a `console.error` and not a throw, so you
289
+ get a frozen screen and a growing console instead of an error you can catch.
290
+
291
+ `useOIMField` and `useOIMBucketSlots` exist to write that `useMemo` once, so no call site has to.
292
+
293
+ **This requirement does not apply to the key of a by-key hook.** `useOIMEntity`,
294
+ `useOIMBucketSlotsByKey`, `useOIMComputedValueByKey` and, through it, `useOIMSingleComputedValue`
295
+ share one body, because their key cannot be kept stable: a tree-driven key is an *array*. So the hook settles its
296
+ key before its memo sees it, by asking the collection which identity each names. Two keys that resolve to one
297
+ identity are one key.
298
+
299
+ ## Things worth knowing
300
+
301
+ - **The snapshot `useOIMValue` compares is a local counter, not the value.** `createOIMEntityWriterInPlace` keeps
302
+ an entity's object reference stable, a bucket hands its slots back as the same live container, and a column
303
+ subscription fires only for its own column; a value-comparing binding would fire on none of the three. The counter
304
+ is bumped ON SUBSCRIBING too, so a write that lands between the render and the subscription is still shown. The
305
+ price is one extra render per SUBSCRIPTION commit, not per render and not per notification.
306
+ - **A subscription holds what it reads, and gives it back when it is torn down.** The by-key hooks resolve with
307
+ `holdSlotByKey` and give that hold straight back once the effect exists, so no root is counted twice.
308
+ `useOIMField` holds its ENTITY slot for itself; its root is the COLUMN, minted inside the subscription.
309
+ - **Services are yours, at module scope.** The provider carries the database and nothing else. A computed service
310
+ is one per kind of computed; a component that owns a computed gets only a store slot from
311
+ `useOIMComputedCollection` / `useOIMSingleComputedValue`.
312
+ - **Nothing re-files an entity when it is written into a bucket you maintain.** Write the buckets in the same code
313
+ that writes the entity, or use an index service (`createOIMIndexService`), which files entities by the key it
314
+ derives from each one.
315
+ - **A bucket never sorts.** A list sorted by a column is `createOIMComputedServiceSorted({ indexService, sortColumns })`
316
+ over an index service, with `createSortedStoreSlot(indexStoreSlot)`.
317
+
318
+ ## Reference
319
+
320
+ The full core API is in **`llms.txt`**, which ships inside this package: read it beside this
321
+ README in the installed package, or
322
+ [on GitHub](https://github.com/abaikov/oimdb/blob/master/llms.txt).