React invoice app
Audience: a React developer who wants an encrypted, offline-first data layer without adding a state library. Bundle: core +
@noy-db/in-react(hooks) +@noy-db/to-browser-idb(persistence) — no opt-in services required.
An invoice list you can add to, edit, and filter — where every record is encrypted before it touches IndexedDB, and every write re-renders exactly the components that read it. The hooks are built on React 18's useSyncExternalStore, so noy-db is the state layer: no Redux, no context-of-arrays, no cache invalidation.
Verified by: showcases/src/recipe-react-invoices.recipe.test.tsx — the code below is the same code that runs there.
Prerequisites
- React 18+ — the hooks rely on
useSyncExternalStore. @noy-db/in-react+@noy-db/hub(and@noy-db/to-browser-idbfor persistence):pnpm add @noy-db/hub @noy-db/in-react @noy-db/to-browser-idb- Nothing else — no
with*()service is needed for this recipe; reactive lists and queries are core behavior surfaced through the hooks.
Setup
One typed collection, one provider:
import { createNoydb } from '@noy-db/hub'
import { toBrowserIdb } from '@noy-db/to-browser-idb'
import { NoydbProvider } from '@noy-db/in-react'
interface Invoice {
id: string
number: string
customer: string
amount: number
status: 'draft' | 'sent' | 'paid'
}
const db = await createNoydb({
store: toBrowserIdb(), // encrypted persistence in IndexedDB
user: 'me',
secret: 'react-recipe-pass-2026', // in a real app: collected at unlock, not hardcoded
})
const vault = await db.openVault('acme')
export function App() {
return (
<NoydbProvider db={db}>
<InvoiceList />
<BigInvoices />
</NoydbProvider>
)
}
A reactive list — useCollection
import { useCollection } from '@noy-db/in-react'
function InvoiceList() {
const { data, loading } = useCollection<Invoice>(vault, 'invoices')
if (loading) return <p>Loading…</p>
return (
<ul>
{data.map((inv) => (
<li key={inv.id}>{inv.number} — {inv.customer} — {inv.amount}</li>
))}
</ul>
)
}
Writes are plain collection calls — from a form handler, a keyboard shortcut, or a sync pull; every component reading the collection re-renders:
const invoices = vault.collection<Invoice>('invoices')
// add
await invoices.put('inv-2', { id: 'inv-2', number: '2026-002', customer: 'Globex', amount: 125000, status: 'draft' })
// edit is the same put() — the row updates in place
await invoices.put('inv-2', { id: 'inv-2', number: '2026-002', customer: 'Globex', amount: 125000, status: 'sent' })
A filtered view — useQuery
useQuery keeps the result of any query builder reactive — new matching writes update the view:
import { useQuery } from '@noy-db/in-react'
function BigInvoices() {
const { data } = useQuery<Invoice, Invoice[]>(
vault, 'invoices',
(q) => q.where('amount', '>', 10_000).toArray(),
)
return <p>{(data ?? []).length} large invoice(s)</p>
}
Lock and unlock
db.close() wipes the in-memory keys — the "lock". The ciphertext stays in IndexedDB; reopening with the secret restores access:
db.close() // lock: keys wiped, store keeps only ciphertext
const db2 = await createNoydb({ store: toBrowserIdb(), user: 'me', secret: 'react-recipe-pass-2026' })
const vault2 = await db2.openVault('acme')
await vault2.collection<Invoice>('invoices').get('inv-1')
// → { id: 'inv-1', number: '2026-001', customer: 'ABC Trading', ... }
In a real app the secret comes from an unlock screen — or from a passkey via on-webauthn so there's nothing to type at all.
Verify the encryption yourself
With the app running, open DevTools → Application → IndexedDB: every stored envelope's _data is opaque ciphertext — the same experiment as See the ciphertext, in the browser.
Where to go from here
- Sync across devices: add
syncStrategy: withSync()and a shared store — see the collaborative app recipe. - History/undo:
historyStrategy: withHistory()— see Your first service. - Next.js:
in-nextjsre-exports these hooks under/clientwith App Router server helpers.
See also
- in-react — the full five-hook surface (
useNoydb,useVault,useSync). - to-browser-idb — the persistence layer and its eviction notes.
- Recipes overview — the composition model when this recipe stops being enough.