ReadyNative
Data
State
Forms
Testing

Add your first feature

Build a Notes tab end to end - a route, a data hook against a public API, a persisted drafts store, a validated form, a list and a test.

Hey - let's put something real in the app. By the end you'll have a Notes tab that loads notes from a public demo API, keeps your own drafts on the device, adds one through a validated form, and has a test that proves the list renders.

I wrote this against the default preset: TanStack Query, Zustand, React Hook Form and jest. Pick a different option in the Your stack toggle above and the snippets that depend on it switch too.

Before you start

  • Tier: any. Free has no gen script and no test runner; step 1 and step 6 say what to do there.
  • Time: about 30 minutes.
  • Runs in: Expo Go. Nothing here adds native code.
  • Accounts: none. The notes come from JSONPlaceholder, a free fake REST API, so you see real data without building a backend.
  • Previous tutorial: Make it yours (optional - this page doesn't depend on it).

Every snippet imports the kit from @/components/ui, where it lives in the finalized tree. If you ran setup with --keep-modules, write @/ui instead.

1. Generate the tab

bun run gen screen notes --tab --icon note.text --icon-fallback description --title "Notes"

That writes src/screens/notes/notes-screen.tsx, the route src/app/(tabs)/notes.tsx, a <NativeTabs.Trigger> entry at the {/* gen:tabs */} marker in src/app/(tabs)/_layout.tsx (the native tab bar: Liquid Glass on iOS 26, Material 3 on Android), and the "Notes" key in every src/locales/*.json. If the marker isn't there, gen prints the block to paste instead.

The Free tier ships without gen, so create the route by hand:

src/app/(tabs)/notes.tsx
import { NotesScreen } from "@/screens/notes/notes-screen";

export default function Route() {
  return <NotesScreen />;
}

Then add a trigger next to the settings one in src/app/(tabs)/_layout.tsx:

src/app/(tabs)/_layout.tsx
<NativeTabs.Trigger name="notes">
  <NativeTabs.Trigger.Icon sf="note.text" md="description" />
  <NativeTabs.Trigger.Label>Notes</NativeTabs.Trigger.Label>
</NativeTabs.Trigger>

sf is the SF Symbol iOS draws, md the Material Symbol for Android. Step 5 writes the screen file itself.

bun run start

You should see: a Notes tab after Settings, with a "Notes" title on an empty screen.

2. Fetch the notes

Hooks live in src/hooks/. This one returns the same shape - notes, loading, error, reload - whichever data option you picked, so the screen in step 5 doesn't care which.

fetchJson is the typed client in src/lib/api/client.ts. It prefixes EXPO_PUBLIC_API_URL to relative paths and passes absolute URLs through, so the demo API works with no key set.

src/hooks/use-notes.ts
import { useQuery } from "@tanstack/react-query";

import { fetchJson } from "@/lib/api/client";

export interface Note {
  id: number;
  title: string;
  body: string;
}

export const NOTES_URL = "https://jsonplaceholder.typicode.com/posts?_limit=5";
export const notesQueryKey = ["notes"] as const;

export function useNotes() {
  const query = useQuery({
    queryKey: notesQueryKey,
    queryFn: () => fetchJson<Note[]>(NOTES_URL),
  });

  return {
    notes: query.data ?? [],
    loading: query.isPending,
    error: query.error,
    reload: () => void query.refetch(),
  };
}

You should see: nothing new on screen yet - the hook isn't used until step 5 - and bun run typecheck passing.

3. Keep drafts on the device

Stores live in src/stores/. useDrafts() returns { drafts, add } for every state option, so the rest of the page reads the same.

zustandStorage is the adapter over whichever storage option you picked, so this persists the same way on kv-store, MMKV and AsyncStorage.

src/stores/drafts.ts
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";

import { zustandStorage } from "@/stores/zustand-storage";

export interface Draft {
  id: string;
  title: string;
  body: string;
}

interface DraftsState {
  drafts: Draft[];
  add: (title: string, body: string) => void;
}

const useDraftsStore = create<DraftsState>()(
  persist(
    (set) => ({
      drafts: [],
      add: (title, body) =>
        set((s) => ({ drafts: [{ id: String(Date.now()), title, body }, ...s.drafts] })),
    }),
    {
      name: "notes:drafts",
      storage: createJSONStorage(() => zustandStorage),
      partialize: (s) => ({ drafts: s.drafts }),
    }
  )
);

export function useDrafts() {
  const drafts = useDraftsStore((s) => s.drafts);
  const add = useDraftsStore((s) => s.add);
  return { drafts, add };
}

You should see: bun run typecheck still passing.

4. Add the form

The form doesn't know where drafts go - it calls onSave, and the screen wires that to the store.

Form and Field from src/components/form bind the kit's Input to React Hook Form, and the zod schema does the validation.

src/screens/notes/note-form.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";

import { Field, Form } from "@/components/form";
import { Box, Button, Card } from "@/components/ui";

const schema = z.object({
  title: z.string().trim().min(2, "At least 2 characters"),
  body: z.string().trim().min(1, "Write something"),
});
type Values = z.infer<typeof schema>;

export function NoteForm({ onSave }: { onSave: (title: string, body: string) => void }) {
  const form = useForm<Values>({
    resolver: zodResolver(schema),
    defaultValues: { title: "", body: "" },
  });

  const submit = form.handleSubmit((values) => {
    onSave(values.title, values.body);
    form.reset();
  });

  return (
    <Box p={4}>
      <Card>
        <Form form={form}>
          <Box gap={3}>
            <Field<Values> name="title" label="Title" placeholder="Morning walk" />
            <Field<Values> name="body" label="Note" placeholder="What happened?" multiline />
            <Button onPress={() => void submit()}>Save</Button>
          </Box>
        </Form>
      </Card>
    </Box>
  );
}

You should see: bun run typecheck passing. The form appears on screen in the next step.

5. Render the list

Replace the generated screen. A list screen is a non-scrolling <Screen padded={false}> with a <List> inside, so the safe-area insets and the scrolling don't fight. List draws the separators; Row draws none.

src/screens/notes/notes-screen.tsx
import type { ReactNode } from "react";

import { useNotes } from "@/hooks/use-notes";
import { useDrafts } from "@/stores/drafts";
import { EmptyState, ErrorState, Icon, List, Loading, Row, Screen } from "@/components/ui";

import { NoteForm } from "./note-form";

interface Item {
  key: string;
  title: string;
  body: string;
  draft: boolean;
}

export function NotesScreen() {
  const { notes, loading, error, reload } = useNotes();
  const { drafts, add } = useDrafts();

  const items: Item[] = [
    ...drafts.map((d) => ({ key: `draft-${d.id}`, title: d.title, body: d.body, draft: true })),
    ...notes.map((n) => ({ key: `api-${n.id}`, title: n.title, body: n.body, draft: false })),
  ];

  let empty: ReactNode = (
    <EmptyState title="No notes yet" description="Write your first one above." />
  );
  if (loading) empty = <Loading label="Loading notes" />;
  if (error) {
    empty = <ErrorState title="Couldn't load notes" description={error.message} retry={reload} />;
  }

  return (
    <Screen padded={false}>
      <List
        data={items}
        keyExtractor={(item) => item.key}
        header={<NoteForm onSave={add} />}
        empty={empty}
        onRefresh={reload}
        refreshing={false}
        renderItem={(item) => (
          <Row
            title={item.title}
            subtitle={item.body}
            leading={
              <Icon
                name={item.draft ? "pencil" : "note.text"}
                fallback={item.draft ? "edit" : "description"}
              />
            }
          />
        )}
      />
    </Screen>
  );
}

You should see: the form on top and five notes from the demo API below it (Latin-looking titles - that's JSONPlaceholder). Type a title and a note, tap Save, and your draft appears first with a pencil icon. Save with an empty title and the field shows "At least 2 characters". Reload the app: with Zustand or Jotai your draft is still there.

Spacing, colours and radii are tokens (gap={3}, p={4}), never raw numbers. For anything the kit has no prop for, every primitive takes style - see Styling.

6. Test it

Like the repo's own tests, this one renders inside the kit's ThemeProvider and uses Testing Library v14, where render is async - always await it. The network call is mocked, so the test runs offline.

src/screens/notes/__tests__/notes-screen.test.tsx
import { describe, expect, it, jest } from "@jest/globals";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react-native";

import { ThemeProvider } from "@/components/ui";
import { NotesScreen } from "@/screens/notes/notes-screen";

jest.mock("@/lib/api/client", () => ({
  fetchJson: () => Promise.resolve([{ id: 1, title: "From the API", body: "Mocked note" }]),
}));

describe("NotesScreen", () => {
  it("renders the notes the API returns", async () => {
    // A fresh client per test: no retries, and no garbage-collection timer left running.
    const client = new QueryClient({
      defaultOptions: { queries: { retry: false, gcTime: Infinity } },
    });

    await render(
      <QueryClientProvider client={client}>
        <ThemeProvider>
          <NotesScreen />
        </ThemeProvider>
      </QueryClientProvider>
    );

    expect(await screen.findByText("From the API")).toBeTruthy();
    expect(screen.getByText("Mocked note")).toBeTruthy();
  });
});
bun run test

You should see: the NotesScreen suite pass alongside the tests that shipped with the app.

Check it

bun run typecheck
bun run lint

Both should finish without errors. bun run lint:fix fixes what it can and formats with Prettier.

If it doesn't work

  • "Unable to resolve @/components/ui" (Starter / Pro) - your tree still has modules/ (you ran setup with --keep-modules), so import from @/ui instead.
  • "Unable to resolve @/components/form" or "zustand" - your stack differs from the default preset. Set the Your stack toggle above to what you picked; the snippets change with it.
  • The list shows "Couldn't load notes" - the device can't reach jsonplaceholder.typicode.com. Check the phone's connection and pull to refresh; a corporate network or VPN can block it.
  • The new tab doesn't appear - gen couldn't find the {/* gen:tabs */} marker and printed a block instead. Paste it into src/app/(tabs)/_layout.tsx inside <Tabs>.
  • The test times out on findByText - check that the jest.mock path matches the import in use-notes.ts, and that the file imports from @jest/globals.

More in Troubleshooting.

Congrats 🎉

You shipped a feature: a tab, a data hook, a persisted store, a validated form, a list and a test - all through the same @/components/ui kit the rest of the app uses. To point it at your own backend later, swap NOTES_URL for a relative path and set EXPO_PUBLIC_API_URL (Do I need a backend?). Next, put a real account behind it: Add sign-in.

On this page

Get ReadyNative