React SDK
The React SDK (monorise/react) provides hooks, actions, and services for building frontend applications with monorise. It features built-in caching, optimistic updates, and type-safe data access.
WARNING
Never point the React SDK directly at the monorise API Gateway. Always proxy through your frontend server using the edge-auth proxy pattern.
Setup
import type { Entity, MonoriseEntityConfig } from 'monorise/base';
import Monorise from 'monorise/react';
import { EntityConfig } from '#/monorise';
Monorise.config({
modals: {},
entityConfig: EntityConfig as Record<Entity, MonoriseEntityConfig>,
});Import hooks and actions as named exports after configuration:
import { createEntity, useEntities } from 'monorise/react';Caching
All data-fetching hooks use a Zustand-based store with an isFirstFetched pattern:
- On first render, the hook fetches data from the API and sets
isFirstFetched = true - On subsequent renders (re-mounts, navigation), the hook returns cached data instantly without another API call
- Cache is per-key: entity type, mutual state key, or tag state key
- Cache persists for the entire session (page refresh clears it)
- Use
forceFetchoption or therefetchfunction to bypass cache
// First render: fetches from API
const { entities } = useEntities(Entity.USER);
// Navigate away and come back: returns cached data instantly
const { entities } = useEntities(Entity.USER);Two ways to force a fresh fetch:
// Option 1: Call refetch() — useful for user-triggered refresh (e.g., pull-to-refresh)
const { entities, refetch } = useEntities(Entity.USER);
refetch();
// Option 2: Pass forceFetch option — useful when you always want fresh data on mount
const { entities } = useEntities(Entity.USER, { forceFetch: true });The forceFetch option works on all data-fetching hooks (useEntities, useEntity, useMutuals, useTaggedEntities, etc.).
Optimistic updates
Monorise provides *Local* functions that update the UI immediately before the server responds. This makes the app feel instant.
// Server action — waits for response
await createEntity(Entity.USER, { name: 'Alice' });
// Optimistic — updates UI immediately, no server call
updateLocalEntity(Entity.USER, userId, { name: 'Alice (edited)' });Optimistic functions use Immer for immutable state updates and update both sides of mutual relationships automatically.
Hooks
All data-fetching hooks accept an optional opts parameter (CommonOptions) and return a consistent set of properties:
Common options
{
forceFetch?: boolean; // bypass cache and always fetch from API
isInterruptive?: boolean; // show interruptive loading indicator
limit?: number; // max results per page (default: 20)
customUrl?: string; // override the default API URL
stateKey?: string; // custom store state key
feedback?: {
success?: string; // toast message on success
failure?: string; // toast message on failure
loading?: string; // toast message while loading
};
}Common return properties
Every data-fetching hook returns at least:
| Property | Type | Description |
|---|---|---|
isLoading | boolean | Whether a request is in flight |
isFirstFetched | boolean | Whether data has been fetched at least once |
error | ApplicationRequestError | undefined | Error from the last request |
requestKey | string | Unique key for this request (useful with useLoadStore/useErrorStore) |
refetch | () => Promise<...> | Force a fresh fetch from the API |
List hooks additionally return:
| Property | Type | Description |
|---|---|---|
lastKey | string | undefined | Pagination cursor — present when more data is available |
listMore | () => void | Load next page of results |
useEntities
Fetch a list of entities with search and pagination.
const {
entities, // CreatedEntity<T>[]
entitiesMap, // Map<string, CreatedEntity<T>>
isLoading,
isFirstFetched,
error,
searchField, // { value: string, onChange: (e) => void }
lastKey,
listMore,
refetch,
requestKey,
} = useEntities(Entity.USER, { limit: 100 }, opts?);The second argument accepts { limit?, all?, skRange? }. The limit controls how many entities are fetched per page (default: 20). listMore respects the same limit.
// Fetch all entities (no limit)
const { entities } = useEntities(Entity.USER, { all: true });
// Fetch with custom page size
const { entities, listMore } = useEntities(Entity.USER, { limit: 50 });The searchField helper can be bound directly to an input:
<input {...searchField} placeholder="Search users..." />useEntity
Fetch a single entity by ID.
const {
entity, // CreatedEntity<T> | undefined
isLoading,
isFirstFetched,
error,
refetch,
requestKey,
} = useEntity(Entity.USER, userId, opts?);useEntityByUniqueField
Fetch an entity by a unique field value (e.g., email).
const {
entity, // CreatedEntity<T> | undefined
isLoading,
isFirstFetched,
error,
refetch,
requestKey,
} = useEntityByUniqueField(Entity.USER, 'email', 'alice@example.com', opts?);useMutuals
Fetch entities related to a given entity via mutual relationships.
const {
mutuals, // Mutual<B, T>[]
mutualsMap, // Map<string, Mutual<B, T>>
isLoading,
isFirstFetched,
error,
lastKey,
listMore,
refetch,
requestKey,
} = useMutuals(Entity.TENANT, Entity.ORGANISATION, tenantId, opts?);Each mutual object contains:
{
entityId: string; // the related entity's ID
entityType: Entity; // the related entity's type
byEntityId: string; // the source entity's ID
byEntityType: Entity; // the source entity's type
mutualId: string;
data: EntitySchemaMap[T]; // the related entity's data (strongly typed)
mutualData: Record<string, any>; // relationship-specific data
createdAt: string;
updatedAt: string;
mutualUpdatedAt: string;
}useMutual
Fetch a single mutual relationship.
const {
mutual, // Mutual<B, T> | undefined
isLoading,
isFirstFetched,
error,
refetch,
requestKey,
} = useMutual(Entity.TENANT, Entity.ORGANISATION, tenantId, organisationId, opts?);useTaggedEntities
Fetch entities by tag using a group, exact sort-value query, or sort range filter. Supply at least one filter in params; refetch and listMore currently require params.group.
const {
entities, // CreatedEntity<T>[]
entitiesMap, // Map<string, CreatedEntity<T>>
isLoading,
isFirstFetched,
error,
lastKey,
listMore,
refetch,
requestKey,
} = useTaggedEntities(Entity.ORGANISATION, 'type', {
...opts?,
params: { group: 'club' },
});useEntityState
Access raw entity store state for a given entity type.
const entityState = useEntityState(Entity.USER);Mutation hooks
Mutation hooks bind the entity or mutual identity at render time and expose the mutation together with its reactive request state:
const {
mutate: editGame,
isLoading,
error,
clearError,
requestKey,
} = useEditEntity(Entity.GAME, gameId);
const result = await editGame({ status: 'completed' });Every mutation hook returns:
| Property | Type | Description |
|---|---|---|
mutate | (...args) => Promise<{ data } | { error }> | Runs the corresponding mutation action. |
isLoading | boolean | Whether a matching mutation is in flight. |
error | ApplicationRequestError | undefined | Normalized error from the last matching mutation. A later success clears it. |
clearError | () => void | Explicitly clears the stored error. It does not cancel a request or change cached data. |
requestKey | string | Stable key used to observe this logical mutation. |
The state is shared by logical target, not local to one component. Calling the existing action function, another instance of the same hook, or an executeTransaction operation for that target updates the same isLoading and error state.
Entity mutation hooks:
| Hook | mutate arguments |
|---|---|
useCreateEntity(entityType, opts?) | (data, opts?) |
useUpsertEntity(entityType, id, opts?) | (data, opts?) |
useEditEntity(entityType, id, opts?) | (data, opts?) |
useAdjustEntity(entityType, id, opts?) | (adjustments, opts?) |
useDeleteEntity(entityType, id, opts?) | (opts?) |
Mutual mutation hooks:
| Hook | mutate arguments |
|---|---|
useCreateMutual(byType, entityType, byId, entityId, opts?) | (payload?, opts?) |
useEditMutual(byType, entityType, byId, entityId, opts?) | (payload?, opts?) |
useDeleteMutual(byType, entityType, byId, entityId, opts?) | (opts?) |
Hook-level options are defaults. Options passed to mutate override them, while feedback fields are merged:
const { mutate: createEvent, isLoading } = useCreateEntity(
Entity.GAME_EVENT,
{ isInterruptive: false },
);
await createEvent(event, {
feedback: { success: 'Event recorded' },
});Writes are sent separately by default, even when two concurrent calls have the same mutation target. This prevents non-idempotent creates, adjustments, and other writes from being silently dropped. Pass the same explicit requestKey when deduplication is intentional, such as sharing the in-flight result of a double-click:
const { mutate: createUser } = useCreateEntity(Entity.USER, {
requestKey: `create-user/${submissionId}`,
});An explicit key changes HTTP deduplication only; the hook continues to expose state under its stable requestKey.
createMutual hook state is target-specific. If you manually observe it with getMutualRequestKey, include the target ID: getMutualRequestKey('create', byType, entityType, byId, entityId). The previous four-argument key remains active for backwards compatibility, but it observes every create from that source rather than one target.
Transactions have the same interface. Operations are bound when the hook is created:
import { transactional as tx, useExecuteTransaction } from 'monorise/react';
const transaction = useExecuteTransaction([
tx.createEntity(Entity.GAME_EVENT, event),
tx.updateEntity(Entity.GAME, gameId, gameUpdate),
]);
await transaction.mutate();While it runs, transaction.isLoading is true and each contained operation also activates its matching entity or mutual mutation hook.
Utility hooks
| Hook | Purpose |
|---|---|
useConfig | Access entity configuration |
useLoadStore(requestKey) | Loading state for a specific request |
useErrorStore(requestKey) | Error state for a specific request |
useModalStore | Modal open/close state |
Actions
Entity actions
| Action | Description |
|---|---|
createEntity(entityType, data, opts?) | Create entity on server. Returns { data } or { error }. |
editEntity(entityType, id, data, opts?) | Partial update entity (sets fields to exact values). For incrementing/decrementing numbers, use adjustEntity instead. Returns { data } or { error }. |
adjustEntity(entityType, id, adjustments, opts?) | Safely increment/decrement numeric fields. Returns { data } or { error }. |
upsertEntity(entityType, id, data, opts?) | Insert or full replace. Returns { data } or { error }. |
deleteEntity(entityType, id, opts?) | Delete entity. Returns { data } or { error }. |
getEntity(entityType, id) | Fetch single entity (non-hook). |
listMoreEntities(entityType, opts?) | Load next page of entities. |
editEntity
Partially update an entity by setting fields to exact values.
import { editEntity } from 'monorise/react';
await editEntity(Entity.USER, userId, {
name: 'Alice Smith',
role: 'admin',
});Only the fields you pass are updated — other fields remain unchanged. The updated entity propagates to mutual and tag stores automatically.
TIP
If you need to increment or decrement a numeric field (e.g., a counter or running total), use adjustEntity instead. editEntity sets the field to the value you provide, which can cause data loss if multiple updates happen concurrently.
Conditional updates: Use $condition to enforce a precondition defined in updateConditions:
// Entity config
const config = createEntityConfig({
name: 'post',
baseSchema,
updateConditions: {
publish: { status: { $eq: 'draft' } },
archive: (data) => ({ status: { $ne: 'archived' } }),
},
});
// Only publishes if current status is 'draft'
await editEntity(Entity.POST, postId, {
status: 'published',
$condition: 'publish',
});$condition is always optional for editEntity — omitting it performs an unconditioned update (current behavior). If the condition is not met, the API returns a 409 Conflict error.
WARNING
editEntity also supports a legacy $where key that takes raw condition operators directly (e.g. { $where: { status: { $eq: 'draft' } } }). It's deprecated and disabled by default — raw operators let a client probe field existence or values via the response status, which $condition is designed to prevent. Only enable it per-entity via allowLegacyWhere: true in that entity's config if you trust the caller.
adjustEntity
Safely increment or decrement numeric fields on an entity. Unlike editEntity which sets a field to a specific value, adjustEntity adds or subtracts a delta — meaning multiple concurrent adjustments never overwrite each other.
Why not editEntity? Imagine two requests try to increment a counter from 100 at the same time:
- With
editEntity: both read 100, both write 101. You lose one increment. - With
adjustEntity: both send "+1". The result is 102. No data loss.
Use adjustEntity for counters, running totals, scores, or any field that multiple sources update concurrently.
import { adjustEntity } from 'monorise/react';
// Increment sales by $50.00 and count by 1
await adjustEntity(Entity.MONTHLY_SUMMARY, summaryId, {
totalSales: 5000,
count: 1,
});
// Decrement (use negative values)
await adjustEntity(Entity.MONTHLY_SUMMARY, summaryId, {
totalRefunds: -2000,
});Type safety: Only accepts numeric fields from the entity schema. Passing a string field results in a TypeScript error.
No optimistic update: Unlike editEntity, the local cache is only updated after the server confirms success. This is because adjustments may fail (condition violations) or produce different results than expected (concurrent adjustments).
Conditions: Define adjustmentConditions in your entity config to enforce preconditions on adjustments. When defined, the condition option is required — you must specify which condition to apply.
Each condition is either a static check or a function that receives the entity's current data and the adjustment deltas:
const config = createEntityConfig({
name: 'wallet',
baseSchema: z.object({
balance: z.number(),
minBalance: z.number(),
}).partial(),
adjustmentConditions: {
// Dynamic: uses entity data + adjustment deltas
withdraw: (data, adjustments) => ({
balance: { $gte: (data.minBalance ?? 0) + Math.abs(adjustments.balance ?? 0) },
}),
// Ensures post-deposit balance doesn't exceed cap
deposit: (data, adjustments) => ({
balance: { $lte: 1000000 - (adjustments.balance ?? 0) },
}),
},
});Pass the condition name when calling adjustEntity:
// This succeeds (balance: 100, withdraw checks balance >= 0 + 30 = 30 → 100 >= 30 ✓)
await adjustEntity(Entity.WALLET, id, { balance: -30 }, { condition: 'withdraw' });
// This fails (balance: 70, withdraw checks balance >= 0 + 80 = 80 → 70 < 80 ✗)
const { error } = await adjustEntity(Entity.WALLET, id, { balance: -80 }, {
condition: 'withdraw',
});
if (error) {
// entity is automatically refetched with latest state
// show "Insufficient balance" to user
}Conditions are resolved server-side — the client only sends the condition name, never raw operators. This prevents clients from bypassing or modifying the condition logic.
Supported operators in conditions: $eq, $ne, $gt, $lt, $gte, $lte, $exists, $beginsWith.
Conditions are enforced at the database level via DynamoDB ConditionExpressions — they cannot be bypassed by the frontend.
Event publishing: Publishes ENTITY_UPDATED event, so tag and replication processors keep denormalized data in sync — same as editEntity.
transaction
Execute multiple entity operations atomically — all succeed or all fail. Uses DynamoDB TransactWriteItems under the hood.
import { coreService, transactional } from 'monorise/react';
await coreService.transaction([
transactional.createEntity(Entity.ORDER, { customerId: '...', total: 5000 }),
transactional.adjustEntity(Entity.WALLET, walletId, { balance: -5000, $condition: 'withdraw' }),
]);The transactional builder provides autocomplete-friendly, type-safe operation constructors. You can alias it for brevity:
const tx = transactional;
await coreService.transaction([
tx.createEntity(Entity.ORDER, { customerId: '...', total: 5000 }),
tx.adjustEntity(Entity.WALLET, walletId, { balance: -5000, $condition: 'withdraw' }),
tx.updateEntity(Entity.POST, postId, { status: 'published', $condition: 'publish' }),
tx.deleteEntity(Entity.PRODUCT, productId),
]);Supported operations:
| Operation | Description |
|---|---|
createEntity | Create a new entity (with unique field enforcement) |
updateEntity | Partial update (no unique field changes allowed in transactions) |
adjustEntity | Atomic numeric increment/decrement (with condition support) |
deleteEntity | Delete an entity |
createMutual | Link two entities, with mutual data validated against the pair's mutualDataSchema |
updateMutual | Patch a link's mutual data, validated the same way as the standalone editMutual |
deleteMutual | Unlink two entities |
Conditions: adjustEntity and updateEntity support the condition field, referencing adjustmentConditions or updateConditions defined in the entity config.
Mutuals in a transaction: createMutual, updateMutual and deleteMutual take the same arguments, in the same order, as the standalone createMutual, editMutual and deleteMutual (Mutual actions). They let a link change commit together with an entity change — for example, removing a member while keeping a counter that must never drop below one:
import { executeTransaction, transactional as tx } from 'monorise/react';
// adjustmentConditions: { leave: { memberCount: { $gte: 2 } } }
await executeTransaction([
tx.adjustEntity(Entity.TEAM, teamId, { memberCount: -1, $condition: 'leave' }),
tx.deleteMutual(Entity.USER, Entity.TEAM, userId, teamId),
]);If the condition fails, the link is kept and the counter is untouched. A transactional mutual runs the same validation and writes the same records as the standalone call, including an asEntity projection, whose afterCreateEntityHook runs once the transaction commits.
A mutual entry in the result carries the same fields the standalone call returns. For updateMutual that is the stored mutualData, read back after the commit. When the same transaction also updated or adjusted the link's target entity, the entry's data is that entity's committed data, whatever the operation order.
Cache: executeTransaction updates the store exactly as the standalone createMutual, editMutual and deleteMutual do:
- both sides of the link (
useMutuals(by, entity, byId)anduseMutuals(entity, by, entityId)), and souseMutualfor the pair, which reads the same list; - a list that was never fetched is created but not marked fetched, so its first
useMutualsmount still loads it; - lists keyed with a chain query are not touched, as with the standalone calls;
- if an
updateMutual's post-commit read failed, its entry carries no stored values and the cache is left as it was; - while the call is pending, each mutual operation drives the same loading key as its standalone call (
getMutualRequestKey('create' | 'update' | 'delete', …)), and a failure is recorded on those keys too.
Each transaction call gets a unique request key by default, including when its operations have the same shape as an in-flight transaction. Transactions contain non-idempotent writes, so implicitly de-duplicating them could report success without sending the later write. Pass the same opts.requestKey explicitly when you intentionally want multiple calls to share one in-flight request. A createMutual's target id remains in the descriptive part of generated keys.
For reactive transaction state, use useExecuteTransaction. The hook observes a stable transaction key while each HTTP call remains unique by default.
DynamoDB limits: Maximum 100 items per transaction. Each createEntity uses 2+ items (main record + list index + unique fields). createMutual, updateMutual and deleteMutual use 3 items each (plus the projection's items for an asEntity mutual). Other operations use 1 item each. A transaction can't touch the same record twice, so one link can only be created, updated or deleted once per transaction. A createMutual reads both entities before the commit, so it can't link to an entity created in the same transaction.
Atomicity: If any operation fails (condition violation, duplicate unique field, missing entity for delete), the entire transaction is rolled back — no partial writes. A createMutual for an already-linked pair fails with 409 MUTUAL_EXISTS, and an updateMutual or deleteMutual for an unlinked pair with 404 MUTUAL_NOT_FOUND.
Events: Events (ENTITY_CREATED, ENTITY_UPDATED, ENTITY_DELETED, and the same mutual events the standalone createMutual/editMutual/deleteMutual publish) are published only after the transaction commits successfully. Replication, tag, and mutual processors work normally.
Mutual actions
| Action | Description |
|---|---|
createMutual(byType, entityType, byId, entityId, payload?, opts?) | Create mutual relationship. |
editMutual(byType, entityType, byId, entityId, payload, opts?) | Update mutual data. |
deleteMutual(byType, entityType, byId, entityId, opts?) | Delete mutual relationship. |
getMutual(byType, entityType, byId, entityId) | Fetch single mutual (non-hook). |
Auto-propagation
When you call createEntity, editEntity, or deleteEntity, the store automatically propagates changes to related stores:
- Create: New entity is added to matching tag stores and mutual stores (based on
mutualFieldsin the entity config) - Edit: Updated data propagates to mutual stores (both sides) and tag stores
- Delete: Entity is removed from all mutual and tag stores
This means useMutuals and useTaggedEntities reflect changes immediately without a manual refetch.
WebSocket Hooks
Monorise provides WebSocket hooks for real-time updates. These are documented in detail on the WebSocket page.
| Hook | Purpose |
|---|---|
useEntityFeed(opts) | Graph-aware real-time feed for an entity — recommended for most apps |
useEntitySocket(entityType, opts?) | Subscribe to all CRUD events for an entity type |
useMutualSocket(byType, byId, entityType, opts?) | Subscribe to mutual changes for a specific entity pair |
useEphemeralSocket(channel, opts?) | Non-persisted messages (typing indicators, presence) |
The feed hook auto-updates the same stores that useEntities and useMutuals read from — no new API to learn. See the WebSocket chat example for a complete working app with entity feed, unread tracking, typing indicators, and connection recovery.
