Guide

Build a live task list

Save tasks to PostgreSQL and see changes in two browser tabs.

On this page

Build a React task list backed by PostgreSQL. Clicking Add task saves a row; every open copy of the page receives the updated list without reloading.

Start in a React TypeScript Vite app with the database packages, Zod and TypeScript installed as described in installation. Keep the Vite entrypoints src/main.tsx and src/App.tsx. Put your PostgreSQL connection URL in the app’s .env file:

.env
DATABASE_URL=postgresql://user:password@localhost:5432/my_app
Local demo, public functions

These declarations don’t authenticate callers. Use a local development database with disposable data. Add authentication before using private data.

  1. 1Define the table and connectionDescribe the rows, then tell the server where PostgreSQL lives.

    Create db/schema.ts at the project root. The table gets a generated id; new tasks default to completed: false:

    db/schema.ts
    import { defineSchema, defineTable, v } from '@hulla/db/schema'
    
    export default defineSchema({
      tasks: defineTable({
        title: v.string(),
        completed: v.boolean().default(false),
      }),
    })

    Keep connection settings in db.config.ts, outside browser code. Allow the Vite app to connect to the development service:

    db.config.ts
    import { defineConfig } from '@hulla/db/config'
    
    export default defineConfig({
      database: {
        server: { url: process.env.DATABASE_URL! },
        client: { url: 'http://localhost:3001', credentials: 'include' },
      },
      service: { origins: ['http://localhost:5173'] },
    })

    Generate the schema-bound constructors before declaring your functions:

    Terminal
    bun --bun hulla-db generate

    The command prints Generated database entrypoints. and creates db/_generated/. It does not create database tables or start a server.

  2. 2Declare a query and a mutationRead saved tasks and validate each new title before writing.

    Create db/tasks.ts. The query returns rows; the mutation inserts one row in a transaction and returns it:

    db/tasks.ts
    import { query, mutation } from './_generated/functions'
    import { z } from 'zod'
    
    export const list = query((ctx) =>
      ctx.db.select('tasks').orderBy('title').limit(100)
    )
    
    export const create = mutation({
      input: z.object({ title: z.string().min(1) }),
      handler: (ctx, input) => ctx.db.insert('tasks', input),
    })

    Exporting these declarations makes them browser-callable. Generation discovers them beside the schema; you do not register routes or maintain another export file. The handler always receives ctx first and validated input second.

  3. 3Initialize storage and start developmentProvision the table explicitly, then leave the query service running.

    From the app directory, initialize the schema and start the watcher:

    Terminal
    bun --bun hulla-db init
    bun --bun hulla-db dev

    init prints Database initialized and schema verified. The watcher generates the new function bindings and starts the service at http://127.0.0.1:3001. Leave this terminal running. dev reloads declaration changes; it never resets database data or silently applies an incompatible schema change.

  4. 4Mount the browser appGenerated callables use the public connection settings automatically.

    Replace src/main.tsx with the browser entry below. The generated runtime knows your function contracts and public service settings:

    src/main.tsx
    import { StrictMode } from 'react'
    import { createRoot } from 'react-dom/client'
    import App from './App'
    
    createRoot(document.getElementById('root')!).render(
      <StrictMode>
        <App />
      </StrictMode>
    )

    This file belongs in the browser. The URL points to the query service, not PostgreSQL; the private connection stays in db.config.ts. Importing the generated callables uses the public settings without opening a connection. For application teardown, see connection ownership.

  5. 5Render the live list and add a taskPass generated functions to hooks; call the mutation from an event.

    Replace src/App.tsx:

    src/App.tsx
    import { useMutation, useQuery } from '@hulla/db-react'
    import { create, list } from '../db/_generated/client/tasks'
    
    export default function App() {
      const tasks = useQuery(list)
      const add = useMutation(create)
      return (
        <main>
          <h1>Tasks</h1>
          {tasks.status === 'pending' && !tasks.data && <p>Loading…</p>}
          {tasks.status === 'error' && <p>Could not load tasks.</p>}
          <ul>
            {tasks.data?.map((task) => (
              <li key={task.id}>{task.title}</li>
            ))}
          </ul>
          <button
            onClick={() => {
              void add({ title: 'My first task' })
            }}>
            Add task
          </button>
          {add.state.status === 'error' && <p>Could not save the task.</p>}
        </main>
      )
    }

    In a second terminal, start Vite on the origin allowed by your config:

    Terminal
    bun run dev --port 5173 --strictPort

    Open http://localhost:5173 in two tabs. Click Add task in one. Both lists update after the commit, and the task remains after a reload. An empty database starts with an empty list; there is no seed or local replica to initialize.

Use schemas to add fields and constraints, queries and mutations to add input and shared guards, and authentication before handling private data. For SSR and framework-specific entrypoints, choose a full-stack recipe. If your app only needs server-side database access, start with direct db operations instead.