As V1 shipped , Stellar has 41 screens ( auth, tab screens, modals , settings, multi-step forms, ... ) + 284 components ( modularity principle ) .
In this post, I want to discuss the front-end architecture + decisions made along the way. How we split responsibilities across layers so small components stay small, screens stay readable.
I will be discussing starting from the problem, reasoning, code, trade-offs.
If you have not read it yet, start with How I Added AI to a Finance App With No Backend ,It covers the SQLite ledger this architecture sits on top of.
The problem
Early on, the app was small. You could pass callback props down two levels and call it a day.
Then I added Quick Entry, Detail Entry, Batch Entry, Goals, Recurring bills, Polaris AI, notifications, and a home screen widget. Every feature show similar metrics :money totals. Income on the Dashboard. Net balance on Money. Goal progress after allocation. Forecast charts. Widget snapshot.
Problem: If only the screen where you tapped Save refreshed, the product felt broken. Users would log $200, go to Dashboard, and still see yesterday's balance until they killed the app.
Why: Prop drilling "plz refresh" through a tab nav does not scale. Neither does stuffing every income row into one global React store and hoping components subscribe wisely. You need a rule for where data lives, where UI state lives, and how writers tell readers to update.
Solution: A layered stack with a clear boundary at each level, plus a tiny pub/sub bus that fans out after every DB WRITE.
Separating states
Stores hold what's users typing ( event ). Hooks hold what's saved. Repositories hold SQL. Events glues writers to readers.
Avoid mixing form drafts with ledger truth.
Everything shared by events.
The layer stack
Front-end code follows one direction. UI at the top. SQL at the bottom. Nothing skips a layer.
What each layer is allowed to do
| Layer | Owns | Must not do |
|---|---|---|
| Screen | Layout, navigation, composing hooks + components | Inline SQL, heavy business logic, deep styling |
| Component | JSX, styling, props | Direct database access, global store for ledger rows |
| Hook | React lifecycle, calling repos, event subscriptions | Render JSX |
| Store (Zustand) | Form fields, modal visibility, UI prefs | Pretend to be the ledger |
| Repository | SQL reads and writes | Know about React |
| Utils | Pure functions | Import React or touch SQLite |
I think separating it like this make your codebase looks better organized and easier to debug.
Layer 1: Screens stay thin
A screen file should read like a table of contents. You should see which data hooks it uses and which components it renders. No business logics or pure functions here.
The Dashboard screen pulls from four domain hooks and one notification selector. It doesnt import a single repository.
export default function Dashboard() {
const { user, refreshUser } = useUserProfile();
const { refreshEntries: refreshMoneyFlow } = useMoneyFlow();
const {
snapshot,
spendingChartData,
recurringChartData,
monthEntries,
priorMonthTotalSpent,
refresh: refreshExpenseSummary,
} = useExpenseSummary();
const { recurringExpenses, upcomingExpenses, overdueExpenses, refreshAll: refreshRecurring } =
useRecurringExpenses();
const unreadCount = useNotificationStore((s) => s.unreadCount);
// ... compose story cards, nudges, pull-to-refresh ...
}Pull-to-refresh on Dashboard calls each hook's refresh method. Thats the orchestration. The screen decides when to reload. Hooks decide how.
Same pattern on Money, Goals, Polaris. Route file in, hooks out, components in the middle.
Layer 2: Components stay dumb
NetSummaryCard renders a balance card. It accepts an optional date range and a view mode toggle. It does not know Quick Entry exists.
export function NetSummaryCard({ dateRange, ...cardProps }: NetSummaryCardProps) {
const [viewMode, setViewMode] = useState<BalanceViewMode>('monthly');
const effectiveDateRange = useMemo(() => {
if (dateRange) return dateRange;
return viewMode === 'monthly' ? getCurrentMonthRange() : undefined;
}, [dateRange, viewMode]);
const { totalIncome, totalExpenses, netIncome, isOverAllocated } =
useNetIncome(effectiveDateRange);
return (
...
);
}This is and example structure of how I organize my components by it's domain , when shits break, you can kinda tell where to look.
| Folder | Examples |
|---|---|
components/ui/ | Button, TextField, Modal, Toast |
components/money/ | All the components under the money screen |
components/modals/ | Pop up modals |
components/dashboard/ | Dashboard related components |
components/goals/ | Goals screen related |
components/polaris/ | All the components under Polaris route |
I tried to keep all the global , reusable components in the ui folder, but each screens make up of smaller components as well. I like group them together by their domain.
Layer 3: I fuggin love Zustand
Zustand makes my life so much easier. One store for each major form or UI surface. Avoid unnecessary re-renders, save your compute power.
Here is an example of how we define the shape of the store :
interface QuickEntryState {
entryType: EntryType;
serviceAmount: string;
tipsAmount: string;
incomeMode: QuickIncomeMode;
selectedDate: QuickSelectedDate;
expenseAmount: string;
isSubmitting: boolean;
errors: QuickEntryErrors;
}What goes in Zustand:
| Store | Holds |
|---|---|
quickEntryStore | Quick form fields and validation |
detailEntryStore | Detail form + goal allocation map |
quickEntryModalStore | Modal open/close (FAB and Dashboard share it) |
globalUIStore | Recent amounts, filter prefs (persisted to AsyncStorage) |
expenseScreenStore | Active tab, period filter on Money |
What does not go in Zustand:
- Lifetime income rows
- Net balance totals
- Goal balances after save
What I learned: if it would survive as financial history in SQLite, it does not belong in a store. If it is "what's happening right now" or "is this modal open," it belongs in Zustand.
Stores export selector hooks so components only re-render when their slice changes.
Remember : useShallow . Object selectors wrap with useShallow to avoid infinite loops when returning { a, b } literals.
Layer 4: Hooks for what was saved
Hooks are where React meets the database. They are the only layer (besides repositories) that should run SQL, through repository classes.
The useEntity skeleton
We have six entity types that share the same load/subscribe/refresh pattern: income, expense, goals, recurring, and similar. Instead of copying useEffect blocks six times, we built one generic hook.
export function useEntity<T>(config: UseEntityConfig<T>): UseEntityReturn<T> {
const { loadEntries: loadEntriesFn, subscribeEvents, entityName, transformEntries } = config;
const [entries, setEntries] = useState<T[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadEntries = useCallback(async () => {
try {
setIsLoading(true);
setError(null);
const fetchedEntries = await loadEntriesFn();
setEntries(fetchedEntries);
} catch (err) {
setError(extractErrorMessage(err, `Failed to load ${entityName}`));
} finally {
setIsLoading(false);
}
}, [loadEntriesFn, entityName]);
// one time hydration from SQLite ( SQLite is external to React’s render tree) when the hook mounts.
useEffect(() => {
loadEntries();
}, []);
useDataEvents(subscribeEvents, loadEntries);
return { entries, isLoading, error, refresh: loadEntries, setEntries, /* ... */ };
}useIncome wraps this:
const { entries, isLoading, error, refresh: loadEntries, setEntries } = useEntity<IncomeEntry>({
loadEntries: () => repository.getIncomeEntriesPage({ limit: 50 }),
subscribeEvents: ['income:changed'],
entityName: 'income entries',
transformEntries: sortByDateDesc,
});useNetIncome is different. It never holds an array of rows. It runs SQL aggregates (SUM(yourShare)) for a date range and returns four numbers. Same event subscriptions, different read shape. That keeps Dashboard cards fast even when the user has years of entries.
| Hook type | Returns | Example |
|---|---|---|
| Entity list | Paginated rows | useIncome, useExpense, useGoals |
| Aggregate | Totals only | useNetIncome, useLedgerTotals |
| Merged view | Combined sources | useMoneyFlow (income + expense list) |
| Form bridge | Save handler | useQuickEntryForm |
Layer 5: The actions bridge
Problem: Submitting Quick Entry touches four things: read form store, validate, call hook to write SQLite, reset form, close modal, update recent amounts pref. That is too much for a component onPress handler.
Why Zustand stores should not import repositories. Screens should not own validation rules for three entry modes. You need one orchestration module that stores can stay dumb around.
Solution: entryActions.ts. Plain functions that read store state via getState(), validate with pure helpers, call hook methods passed in as parameters, then reset UI.
Flow for Quick Entry save:
submitQuickEntry()readsquickEntryStoreviaquickEntryActions.getState()- Pure validators check amounts and dates
- Calls
addIncome()fromuseIncome(passed in from the hook layer) - Updates
globalUIStorerecent amounts - Resets form and closes modal
The store never saw SQL. The screen never saw validation math. The hook owns persistence and events.
Layer 6: The event bus
This is the piece that connects 150+ components without coupling them.
After every successful write, the hook emits:
const id = await repository.createIncomeEntry(newEntry);
await loadEntries();
dataEvents.emit('income:created');
dataEvents.emit('income:changed');The bus itself is small. A Map of event names to listener sets. Subscribe returns an unsubscribe function. Emit calls every listener synchronously.
export type DataEventType =
| 'income:created'
| 'income:updated'
| 'income:deleted'
| 'income:changed'
| 'expense:changed'
| 'goal:allocated'
| 'recurring:changed'
| 'polaris:contribution:changed'
// ...
emit(event: DataEventType): void {
this.listeners.get(event)?.forEach((listener) => {
try { listener(); } catch (error) { console.error(/* ... */); }
});
}We emit both specific events (income:created) and umbrella events (income:changed). Most subscribers listen to the umbrella. Analytics or audit hooks can listen to specific ones later.
React hooks subscribe through useDataEvents, which uses a callback ref so changing the callback identity does not re-subscribe every render:
export function useDataEvents(events: DataEventType[], callback: () => void): void {
const callbackRef = useRef(callback);
callbackRef.current = callback;
// sync React's lifecycle with the event bus.
useEffect(() => {
const unsubscribers = events.map((event) =>
dataEvents.subscribe(event, () => callbackRef.current())
);
return () => unsubscribers.forEach((unsub) => unsub());
}, [events.join(',')]);
}Who listens when income changes?
One save in Quick Entry fans out to every mounted hook that cares:
| Subscriber | What it refreshes |
|---|---|
useIncome | Recent income list on Money tab |
useNetIncome | Balance card totals |
useLedgerTotals | Dashboard net figure |
useMoneyFlow | Combined income + expense timeline |
useQuickStats / useFinStats | Period and year-to-date stats |
useForecast | Projection charts |
useWidgetSync | Debounced home screen widget push |
Quick Entry does not import Dashboard. Dashboard does not import Quick Entry. They meet at the bus.
Debugging is grep: search emit('income: for writers, subscribe('income: or subscribeEvents: ['income:changed'] for readers.
End-to-end: one save, five surfaces update
Here is the full path when a nail tech logs $200 service + $30 tip between clients.
The user sees one spinner in the modal. Five surfaces update without a central "refresh everything" function.
Optimistic updates without lying to the user
SQLite writes are fast but not instant. We show the new row immediately, then confirm against the database.
await withOptimisticUpdate({
getCurrentState: () => entries,
setOptimisticState: setEntries,
optimisticState: entries.filter(e => e.id !== id),
operation: () => repository.deleteIncomeEntry(id),
onSuccess: () => loadEntries(),
});Steps:
- Snapshot previous state
- Apply optimistic UI (add temp id like
temp_abc123) - Run real async DB operation
- On success, reload from DB (source of truth wins)
- On failure, restore snapshot
Temp ids let the list render before SQLite returns a real UUID. After reload, temp rows disappear and real rows replace them.