Skip to content

State Management, Architecture & i18n Best Practices

Status: Reference

React Native (Expo SDK 55) + Hexagonal Arch + Zustand v5 + TanStack Query v5 + Zod + custom i18n Researched 2026-03-24.


Top Priority Actions

#ActionEffortImpact
1Add noUncheckedIndexedAccess to tsconfigLowHigh — catches null bugs
2Make t() reactive (Zustand-based locale)MediumHigh — enables dynamic lang switch
3Add eslint-plugin-boundariesMediumHigh — automated arch enforcement
4Optimistic mutations for RSVP/scoresMediumHigh — perceived performance
5Add husky + lint-stagedLowMedium — quality gate
6Locale parity validation scriptLowMedium — prevents missing translations
7exactOptionalPropertyTypes in tsconfigLowMedium — stricter types

1. State Management

1.1 Server State vs Client State

RULE: TanStack Query owns ALL server-originated data. Zustand owns ONLY client-local state (theme, UI flags, auth tokens). Never duplicate server data in Zustand.

WHY: Duplicating server cache in Zustand creates synchronization bugs — stale data, missing invalidation, double sources of truth.

typescript
// BAD — duplicating server data in Zustand
const useClubStore = create((set) => ({
  clubs: [],
  fetchClubs: async () => {
    const data = await api.getClubs();
    set({ clubs: data }); // Now TWO caches
  },
}));

// GOOD — TanStack Query for server, Zustand for client-only
const useClubs = () =>
  useQuery({
    queryKey: clubKeys.list(),
    queryFn: () => registry.clubRepo.findByUser(userId),
  });
const useUIStore = create<UIState>()((set) => ({
  selectedClubId: null,
  setSelectedClubId: (id) => set({ selectedClubId: id }),
}));

1.2 Zustand Store Design

RULE: Multiple small, domain-scoped stores. Co-locate actions with state. Use useShallow for multi-property selectors. Export typed custom hooks, never raw stores.

WHY: Monolithic stores cause unnecessary re-renders across unrelated features.

typescript
// BAD — subscribing to entire store
const Component = () => {
  const store = useAppStore(); // re-renders on ANY change
};

// GOOD — atomic selectors
const useThemeId = () => useThemeStore((s) => s.themeId);
// Multi-property with useShallow
const useThemeActions = () =>
  useThemeStore(useShallow((s) => ({ themeId: s.themeId, setThemeId: s.setThemeId })));

1.3 Zustand Anti-Patterns

RULE: Never store derived/computed state in Zustand. Never mutate state directly. Never subscribe to entire store.

typescript
// BAD — derived state stored
const useStore = create((set) => ({
  items: [],
  filteredItems: [], // DERIVED — will go stale
}));

// GOOD — derive in selector
const useFilteredItems = () =>
  useStore((s) => s.items.filter((i) => i.name.includes(s.filterText)));

1.4 React Context vs Zustand

RULE: Context for dependency injection (theme provider, registry) and tree-scoped state. Zustand for anything accessed outside React tree, persisted, or needing granular subscriptions.

WHY: Context re-renders ALL consumers when value changes. Zustand has selector-based subscriptions.

1.5 Form State — React Hook Form

RULE: Form state stays in RHF. Push to Zustand only on explicit "save/next" actions, never on every keystroke. Use Zod resolvers for validation.

typescript
// BAD — syncing every keystroke to Zustand
useEffect(() => {
  const sub = watch((data) => setFormData(data));
  return () => sub.unsubscribe();
}, [watch]);

// GOOD — only on explicit action
const onNext = handleSubmit((data) => {
  useFormStore.getState().setStepOneData(data);
  router.push('/step-two');
});

1.6 Navigation State

RULE: URL/route params ARE state. Do not duplicate in Zustand. Use useLocalSearchParams() for screen-local data.

typescript
// BAD
const { clubId } = useLocalSearchParams();
useEffect(() => {
  useClubStore.getState().setActiveClubId(clubId);
}, [clubId]);

// GOOD
const { clubId } = useLocalSearchParams<{ clubId: string }>();
const { data: club } = useClub(clubId); // Query uses param directly

1.7 Hydration — Async Store with Splash Gating

RULE: Wait for Zustand persist hydration AND font loading before hiding splash. Use _hasHydrated flag via onRehydrateStorage. Always set timeout fallback (3-5s).

TwoMore: Already implemented correctly with _hasHydrated and 3s timeout fallback.

1.8 State Persistence Rules

PersistDo NOT Persist
Theme IDServer cache (TanStack Query handles this)
Auth token referenceForm drafts (unless multi-step wizard)
Locale preferenceNavigation state
Onboarding completionTransient UI flags

1.9 Optimistic Mutations

RULE: Use onMutate to snapshot, optimistically update cache, return rollback context. Always invalidateQueries in onSettled.

typescript
const mutation = useMutation({
  mutationFn: (data) => registry.sessionRepo.rsvp(data),
  onMutate: async (newData) => {
    await queryClient.cancelQueries({ queryKey: sessionKeys.detail(id) });
    const previous = queryClient.getQueryData(sessionKeys.detail(id));
    queryClient.setQueryData(sessionKeys.detail(id), (old: Session) => ({
      ...old,
      rsvps: [...old.rsvps, { userId, status: 'confirmed' }],
    }));
    return { previous };
  },
  onError: (_err, _vars, context) => {
    if (context?.previous) queryClient.setQueryData(sessionKeys.detail(id), context.previous);
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: sessionKeys.detail(id) });
  },
});

TwoMore: RSVP, score entry, and guest application approval are prime candidates.


2. Architecture

2.0 Current Mobile/Web Package Boundary

RULE: apps/mobile and apps/web are platform shells. Shared feature packages are router-agnostic and import shared behavior only through @twomore/app plus domain-free UI through @twomore/ui.

Feature source must not import:

  • expo-router
  • next/*
  • app-private @/ aliases
  • sibling feature packages such as @twomore/home, @twomore/sessions, or @twomore/records
  • adapter registries or repository instances

Use the shared facade instead:

typescript
import { appRouter, routes, useClub, useJoinClub } from '@twomore/app';

const joinClub = useJoinClub({
  onSuccess: (member) => appRouter.replace(routes.club(member.clubId)),
});

Enforcement: packages/app/src/config/__tests__/architecture-archunit.test.ts checks the current architecture boundaries. Run yarn check before pushing. Full guide: ../architecture/shared-client-architecture.md.

2.1 Hexagonal Architecture Rules

RULE: Domain has ZERO framework imports. Ports are interfaces. Adapters implement ports. Registry singleton wires adapters. Presentation imports from registry, never adapters directly.

Enforcement: Add eslint-plugin-boundaries:

typescript
'boundaries/dependency-rules': [
  { from: 'domain', disallow: ['adapters', 'application', 'presentation', 'routes'] },
  { from: 'ports', allow: ['domain'] },
  { from: 'adapters', allow: ['domain', 'ports'] },
  { from: 'presentation', allow: ['domain', 'application'] },
]

Also add no-restricted-imports:

typescript
'no-restricted-imports': ['error', {
  patterns: [
    { group: ['@/adapters/*'], message: 'Import from @/registry instead' },
    { group: ['*/generated.types'], message: 'Supabase types must not leak' },
  ],
}],

2.2 Directory Structure

RULE: Layer-based for shared infrastructure (domain/ports/adapters). Feature-grouped within presentation.

2.3 Dependency Injection — Registry Pattern

RULE: Module-level registry singleton. Prefer over Context-based DI. Testing overrides the registry.

TwoMore: Already correctly implemented via src/registry.ts. All runtime adapters are Supabase-backed. Mock adapters are retained for unit tests only.

2.4 Domain Purity

RULE: Entities are Zod schemas with inferred types. Business rules are pure functions. No side effects, no async, no framework imports in domain.

2.5 Use Case Pattern

RULE: Use cases ONLY for multi-step orchestration across multiple ports. Simple CRUD goes directly through registry in hooks.

BAD: FindClubByIdUseCase that just calls repo.findById(). GOOD: HostPickupGameUseCase that creates club + session + notifications.

2.6 Error Domain

RULE: Domain-specific error classes with error codes. Map adapter errors at boundary. Presentation shows i18n messages.

typescript
export class ClubError extends DomainError {
  static nameAlreadyTaken(name: string) {
    return new ClubError('CLUB_NAME_TAKEN', `Club name "${name}" already taken`);
  }
}
// Adapter maps Supabase code 23505 → ClubError.nameAlreadyTaken()
// Presentation maps error.code → t().clubs.errors.nameTaken

2.7 Barrel Exports

RULE: Feature-level index.ts with explicit named exports. No deep barrel chains. No mega re-export-all barrels.


3. Internationalization (i18n)

3.1 String Management

RULE: Nested object structure per locale. Source locale (Korean) IS the TypeScript type definition. Other locales must structurally match.

3.2 Korean Pluralization

RULE: Korean has NO grammatical plurals. Use function-based interpolation with counter words (명/개/경기/면/회).

typescript
// GOOD for Korean
memberCount: (n: number) => `${n}명`;

3.3 Date/Time

RULE: Store as ISO 8601/UTC. Display with Intl.DateTimeFormat using explicit locale. Relative dates via i18n helpers.

3.4 Number Formatting

typescript
new Intl.NumberFormat('ko-KR', {
  style: 'currency',
  currency: 'KRW',
  maximumFractionDigits: 0,
}).format(50000); // ₩50,000

3.5 RTL Preparation

RULE: Prefer logical start/end layout props over physical left/right where the component API supports them. Zero-cost habit.

3.6 Dynamic Language Switching (ACTION ITEM)

CURRENT ISSUE: t() uses a module-level variable. setLocale() changes it but does NOT trigger re-renders.

FIX: Migrate to Zustand-based locale store:

typescript
const useLocaleStore = create<LocaleState>()(
  persist(
    (set) => ({
      locale: 'ko' as Locale,
      setLocale: (locale: Locale) => set({ locale }),
    }),
    { name: 'locale-storage' }
  )
);
export const useTranslation = () => {
  const locale = useLocaleStore((s) => s.locale);
  return translations[locale];
};

4. Code Quality

4.1 TypeScript Strictness (ACTION ITEM)

Add to tsconfig.json:

json
{
  "compilerOptions": {
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noPropertyAccessFromIndexSignature": true
  }
}

4.2 Import Organization

Group: (1) external packages, (2) @/domain, (3) @/ports/@/adapters, (4) @/presentation, (5) relative.

4.3 Linting (ACTION ITEM)

Add ESLint flat config with @typescript-eslint/strict, eslint-plugin-boundaries, eslint-plugin-react-native, eslint-config-prettier.

4.4 Git Hooks (ACTION ITEM)

husky + lint-staged for pre-commit: ESLint + Prettier on staged files.


Sources

Markdown remains the source of truth. Run yarn docs:check before handoff.