# Doura
> A lightweight, intuitive and reactive state management library for React. This is the complete agent-facing reference — each section provides enough context to use the feature, with links to full documentation.
## Quick Start
```bash
npm install doura react-doura
```
```ts
import { defineModel, doura } from 'doura'
const counterModel = defineModel({
name: 'counter',
state: { count: 0 },
actions: {
increment() {
this.count += 1
},
},
views: {
double() {
return this.count * 2
},
},
})
// Standalone usage
const store = doura()
const counter = store.getModel(counterModel)
counter.increment()
console.log(counter.count) // 1
console.log(counter.double) // 2
```
```tsx
// React usage
import { DouraRoot, useModel } from 'react-doura'
function Counter() {
const counter = useModel(counterModel)
return
}
function App() {
return (
)
}
```
Full details: [Installation](https://dourajs.github.io/doura/docs/installation.md) | [Introduction](https://dourajs.github.io/doura/docs/introduction.md)
---
## Defining Models
`defineModel` is the central API for declaring state, logic, and derived data.
It returns a `ModelDefinition` wrapper; the original options live on
`definition.$options`. Pass the definition to stores and React hooks.
```ts
import { defineModel } from 'doura'
const model = defineModel({
name: 'uniqueName', // required: unique string identifier
state: {
/* ... */
}, // required: initial state (plain object)
actions: {
/* ... */
}, // optional: methods that update state
views: {
/* ... */
}, // optional: computed/derived values
models: [
/* ... */
], // optional: array of child model definitions
queries: {
/* ... */
}, // optional: async data fetching with caching
})
```
Key constraint: property names across `state`, `actions`, `views`, `queries`,
and child model names must not conflict. Conflicts are thrown during
`defineModel()`.
### State
State is the initial data shape. Access state properties directly via `this` in actions and views.
```ts
state: {
users: [] as User[],
currentId: null as string | null,
}
```
Full details: [State](https://dourajs.github.io/doura/docs/core-concepts/state.md)
### Actions
Actions change state in three public forms:
```ts
actions: {
// Modify — update via this (most common)
increment() {
this.count += 1
},
// Replace — assign to this.$state
reset() {
this.$state = { count: 0 }
},
// Patch — use this.$patch for partial merge
patchSome() {
this.$patch({ count: 2 })
},
// Async actions
async fetchAndSet() {
const data = await fetch('/api/data').then(r => r.json())
this.data = data
},
}
```
Inside actions, `this` provides access to all state, views, queries, and child models.
Full details: [Actions](https://dourajs.github.io/doura/docs/core-concepts/actions.md) | [API Reference](https://dourajs.github.io/doura/docs/api/core/doura.md#action)
### Views
Views are computed/derived values. They re-evaluate only when their dependencies change.
```ts
views: {
// Shorthand: receives state as argument
double(state) {
return state.count * 2
},
// this-based: can reference other views
quadruple() {
return this.double * 2
},
}
```
Full details: [Views](https://dourajs.github.io/doura/docs/core-concepts/views.md) | [Optimize Views](https://dourajs.github.io/doura/docs/guides/optimize-views.md)
### Model Composition
Compose child models via the `models` option. Children are keyed by each child
definition's `$options.name`:
```ts
const childModel = defineModel({
name: 'child',
state: { value: 0 },
actions: {
inc() {
this.value++
},
},
})
const parentModel = defineModel({
name: 'parent',
state: { own: 'data' },
models: [childModel],
actions: {
doSomething() {
this.child.inc() // access child by its name
},
},
})
```
Named models are shared: if multiple parents compose the same child, they point to the same instance within a store.
Full details: [Composing Models](https://dourajs.github.io/doura/docs/guides/compose-model.md)
### Queries
Built-in async data fetching with caching. Each query entry is a function and maintains a cache keyed by its arguments. Configure per-query options in the optional second argument to `defineModel()`.
```ts
import { defineModel } from 'doura'
const userModel = defineModel(
{
name: 'user',
state: { currentUser: null as User | null },
queries: {
fetchAll: async function (ctx) {
const res = await fetch('/api/users', { signal: ctx.signal })
return res.json()
},
fetchById: async function (ctx, id: string) {
const res = await fetch(`/api/users/${id}`, { signal: ctx.signal })
return res.json()
},
},
},
({ model }) => {
model.setQueryOptions('fetchById', { staleTime: 30_000 })
}
)
```
Every query function receives `QueryCtx` as its first argument, which provides an `AbortSignal` for cancellation.
**Query options** (set via `model.setQueryOptions(name, options)` in setup):
| Option | Type | Description |
| ----------- | -------------------------- | -------------------------------------------------------- |
| `staleTime` | `number` | How long data is fresh (ms). Default: `0` (always stale) |
| `onData` | `(ctx: OnDataCtx) => void` | Callback when data arrives (from fetch or setData) |
`onData` runs in an action context — update state or call actions via `ctx.api`:
```ts
model.setQueryOptions('fetchById', {
staleTime: 30_000,
onData({ api, args, data }) {
api.currentUser = data // sync fetched data into model state
},
})
```
Direct query access is a fetch function:
```ts
const user = await instance.fetchById('user-1')
```
Use `$queries` for cache reads and control methods:
```ts
instance.$queries.fetchById.invalidate('user-1')
instance.$queries.fetchById.setData('user-1', user)
```
**QueryHandle methods** (available as `instance.$queries.queryName`):
| Method | Description |
| ------------------------ | --------------------------------- |
| `fetch(...args)` | Fetch and return data |
| `prefetch(...args)` | Warm cache without awaiting |
| `getData(...args)` | Read cached data without fetching |
| `getState(...args)` | Read raw cache entry |
| `isFetching(...args)` | Check if currently fetching |
| `isStale(...args)` | Check if data is stale |
| `cancel(...args?)` | Cancel inflight request(s) |
| `invalidate(...args?)` | Mark entry/entries stale |
| `reset(...args?)` | Clear entry/entries entirely |
| `setData(...args, data)` | Write data into cache manually |
Full details: [Queries Guide](https://dourajs.github.io/doura/docs/guides/queries.md) | [QueryHandle API](https://dourajs.github.io/doura/docs/api/core/doura.md#queryhandle)
---
## Store
The `doura()` factory creates a store that manages model instances.
```ts
import { doura } from 'doura'
const store = doura({
initialState: { counter: { count: 10 } }, // optional: pre-seed state
plugins: [[myPlugin, options]], // optional: plugin tuples
query: { gcTime: 300_000, staleTime: 0 }, // optional: query defaults
})
```
### Store API
| Method | Description |
| ------------------------- | ---------------------------------------------------------- |
| `getModel(model)` | Get or create a named model instance (singleton per store) |
| `getDetachedModel(model)` | Create an independent instance not tracked by the store |
| `getState()` | Snapshot of all named models' state |
| `subscribe(fn)` | Listen to any state change; returns unsubscribe fn |
| `destroy()` | Tear down all models and plugins |
```ts
const counter = store.getModel(counterModel)
counter.increment()
const unsub = store.subscribe(() => console.log(store.getState()))
unsub()
store.destroy()
```
Full details: [Store](https://dourajs.github.io/doura/docs/core-concepts/store.md) | [API Reference](https://dourajs.github.io/doura/docs/api/core/doura.md#doura)
---
## React Integration
### DouraRoot (Global Store)
Wraps your app to provide a global store context:
```tsx
import { DouraRoot, useModel, useStaticModel } from 'react-doura'
;
```
In dev mode, `DouraRoot` auto-enables the Redux DevTools plugin.
### useModel
Reactive hook that re-renders when accessed state/views change:
```tsx
// Full API access
const counter = useModel(counterModel)
// With selector — only re-renders on selected value changes
const { count, increment } = useModel(
counterModel,
(s) => ({ count: s.count, increment: s.increment }),
[] // deps for selector stability
)
```
### useDetachedModel
Component-scoped isolated model (replaces useState with full Doura features):
```tsx
const counter = useDetachedModel(counterModel)
// Each component instance gets its own independent model
```
### useStaticModel
Non-reactive access (no re-renders). Use for stable references like action methods:
```tsx
const counter = useStaticModel(counterModel)
// counter.increment is stable; counter.count reads won't trigger re-render
```
### createContainer (Multiple Stores)
Creates an isolated store scope:
```tsx
import { createContainer } from 'react-doura'
const { Provider, useSharedModel, useStaticModel } = createContainer()
```
Full details: [Component State](https://dourajs.github.io/doura/docs/react/component-state.md) | [Global Store](https://dourajs.github.io/doura/docs/react/global-store.md) | [Multiple Stores](https://dourajs.github.io/doura/docs/react/multiple-stores.md) | [API Reference](https://dourajs.github.io/doura/docs/api/core/react-doura.md)
---
## useQuery
Subscribe to a query's cache and auto-fetch when stale. Pass a direct
`QueryFetch` (`api.fetchById`), a `QueryHandle` (`api.$queries.fetchById`), or
a definition ref (`userModel.fetchById`). Definition refs resolve through the
current Provider store.
```tsx
import { useQuery } from 'react-doura'
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error, refetch } = useQuery(
userModel.fetchById,
[userId],
{ staleTime: 60_000, enabled: !!userId }
)
if (isLoading) return
Loading...
if (error) return
Error: {String(error)}
return
{data.name}
}
```
**Options** (`QueryOverrides`):
| Option | Type | Description |
| ----------------- | --------------------------- | ------------------------------- |
| `enabled` | `boolean \| () => boolean` | Control whether fetch runs |
| `staleTime` | `number` | Override staleness threshold |
| `select` | `(data) => TSelected` | Transform data before returning |
| `placeholderData` | `TData \| (prev?) => TData` | Show before real data arrives |
**Result** (`UseQueryResult`):
| Field | Description |
| ------------------- | ---------------------------------------- |
| `data` | The query data (or selected/transformed) |
| `error` | Error if fetch failed |
| `isLoading` | No data, no error, enabled |
| `isPending` | No data yet |
| `isFetching` | Fetch in progress |
| `isSuccess` | Has data, no error |
| `isError` | Has error |
| `isStale` | Data missing or older than staleTime |
| `isRefetching` | Has data AND currently fetching |
| `isPlaceholderData` | Showing placeholder data |
| `refetch()` | Manually trigger a refetch |
Full details: [API Reference](https://dourajs.github.io/doura/docs/api/core/react-doura.md#usequery)
---
## useInfiniteQuery
Paginated query that accumulates pages. If a definition ref is rebound through
a different Provider store, accumulated pages reset and the initial page is
loaded again.
```tsx
import { useInfiniteQuery } from 'react-doura'
function PostList() {
const { data, hasNextPage, fetchNextPage, isFetchingNextPage } =
useInfiniteQuery(postsModel.fetchPage, {
initialArgs: [1] as [number],
getNextArgs: (lastPage, allPages) =>
lastPage.hasMore ? ([allPages.length + 1] as [number]) : undefined,
})
return (