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
genscript 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:
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:
<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 startYou 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.
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.
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.
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.
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.
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 testYou should see: the NotesScreen suite pass alongside the tests that shipped with the app.
Check it
bun run typecheckbun run lintBoth 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@/uiinstead. - "Unable to resolve @/components/form" or "zustand" - your stack differs from the
defaultpreset. 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 -
gencouldn't find the{/* gen:tabs */}marker and printed a block instead. Paste it intosrc/app/(tabs)/_layout.tsxinside<Tabs>. - The test times out on
findByText- check that thejest.mockpath matches the import inuse-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.