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
| # | Action | Effort | Impact |
|---|---|---|---|
| 1 | Add noUncheckedIndexedAccess to tsconfig | Low | High — catches null bugs |
| 2 | Make t() reactive (Zustand-based locale) | Medium | High — enables dynamic lang switch |
| 3 | Add eslint-plugin-boundaries | Medium | High — automated arch enforcement |
| 4 | Optimistic mutations for RSVP/scores | Medium | High — perceived performance |
| 5 | Add husky + lint-staged | Low | Medium — quality gate |
| 6 | Locale parity validation script | Low | Medium — prevents missing translations |
| 7 | exactOptionalPropertyTypes in tsconfig | Low | Medium — 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.
// 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.
// 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.
// 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.
// 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.
// BAD
const { clubId } = useLocalSearchParams();
useEffect(() => {
useClubStore.getState().setActiveClubId(clubId);
}, [clubId]);
// GOOD
const { clubId } = useLocalSearchParams<{ clubId: string }>();
const { data: club } = useClub(clubId); // Query uses param directly1.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
| Persist | Do NOT Persist |
|---|---|
| Theme ID | Server cache (TanStack Query handles this) |
| Auth token reference | Form drafts (unless multi-step wizard) |
| Locale preference | Navigation state |
| Onboarding completion | Transient UI flags |
1.9 Optimistic Mutations
RULE: Use onMutate to snapshot, optimistically update cache, return rollback context. Always invalidateQueries in onSettled.
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-routernext/*- 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:
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:
'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:
'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.
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.nameTaken2.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 (명/개/경기/면/회).
// 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
new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW',
maximumFractionDigits: 0,
}).format(50000); // ₩50,0003.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:
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:
{
"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.