Back

Front End Architecture

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

LayerOwnsMust not do
ScreenLayout, navigation, composing hooks + componentsInline SQL, heavy business logic, deep styling
ComponentJSX, styling, propsDirect database access, global store for ledger rows
HookReact lifecycle, calling repos, event subscriptionsRender JSX
Store (Zustand)Form fields, modal visibility, UI prefsPretend to be the ledger
RepositorySQL reads and writesKnow about React
UtilsPure functionsImport 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.

tsx
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.

tsx
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.

FolderExamples
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 :

ts
interface QuickEntryState {
  entryType: EntryType;
  serviceAmount: string;
  tipsAmount: string;
  incomeMode: QuickIncomeMode;
  selectedDate: QuickSelectedDate;
  expenseAmount: string;
  isSubmitting: boolean;
  errors: QuickEntryErrors;
}

What goes in Zustand:

StoreHolds
quickEntryStoreQuick form fields and validation
detailEntryStoreDetail form + goal allocation map
quickEntryModalStoreModal open/close (FAB and Dashboard share it)
globalUIStoreRecent amounts, filter prefs (persisted to AsyncStorage)
expenseScreenStoreActive 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.

ts
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:

ts
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 typeReturnsExample
Entity listPaginated rowsuseIncome, useExpense, useGoals
AggregateTotals onlyuseNetIncome, useLedgerTotals
Merged viewCombined sourcesuseMoneyFlow (income + expense list)
Form bridgeSave handleruseQuickEntryForm

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:

  1. submitQuickEntry() reads quickEntryStore via quickEntryActions.getState()
  2. Pure validators check amounts and dates
  3. Calls addIncome() from useIncome (passed in from the hook layer)
  4. Updates globalUIStore recent amounts
  5. 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:

ts
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.

ts
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:

ts
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:

SubscriberWhat it refreshes
useIncomeRecent income list on Money tab
useNetIncomeBalance card totals
useLedgerTotalsDashboard net figure
useMoneyFlowCombined income + expense timeline
useQuickStats / useFinStatsPeriod and year-to-date stats
useForecastProjection charts
useWidgetSyncDebounced 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.

ts
await withOptimisticUpdate({
  getCurrentState: () => entries,
  setOptimisticState: setEntries,
  optimisticState: entries.filter(e => e.id !== id),
  operation: () => repository.deleteIncomeEntry(id),
  onSuccess: () => loadEntries(),
});

Steps:

  1. Snapshot previous state
  2. Apply optimistic UI (add temp id like temp_abc123)
  3. Run real async DB operation
  4. On success, reload from DB (source of truth wins)
  5. 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.