coding-standards

star 0

Load coding standards and conventions for this React Native / Expo project. Use when you need guidance on TypeScript patterns, Tamagui tokens, Zustand stores, Lingui i18n, Doppler env vars, or Zustand state ownership rules.

ksairi-org By ksairi-org schedule Updated 6/8/2026

name: coding-standards description: Load coding standards and conventions for this React Native / Expo project. Use when you need guidance on TypeScript patterns, Tamagui tokens, Zustand stores, Lingui i18n, Doppler env vars, or Zustand state ownership rules.

Apply the following standards to all code in this project.

If this project uses @ksairi-org/* libraries, run /expo-rn-plugin:libs before writing any utility, hook, or layout code — those packages replace many standard alternatives.

Reference implementation — check this FIRST

Before debugging a build error, adding a package, or configuring anything native, check the reference apps — production apps built on this exact stack:

Rules:

  • Build error or native config question → check both apps' app.config.ts, package.json, and eas.json before any other investigation
  • Adding an Expo SDK package → check if either reference app already uses it and copy its config exactly
  • Tempted to add a config plugin workaround → check if the reference apps need it; if they don't, you probably don't either

The reference apps encode hard-won lessons. Reverse-engineering Gradle/Xcode internals when the answer is already in a reference app wastes time and risks introducing hacks that the SDK would have handled correctly.

Managed workflow — never touch native folders

This is a managed Expo app. The android/ and ios/ folders are gitignored and fully regenerated by EAS on every build. Never commit them, never hand-edit files inside them.

All native configuration goes through config plugins in app.config.ts:

  • Gradle properties → withGradleProperties from @expo/config-plugins
  • Info.plist / entitlements → withInfoPlist, withEntitlementsPlist
  • AndroidManifest → withAndroidManifest
  • Raw file injection → withDangerousMod (last resort only)

If a third-party package requires native changes and has no config plugin, check whether an Expo-maintained fork or wrapper exists before reaching for bare workflow.

Package Installation

Always install Expo SDK packages with yarn expo install, never yarn add.

# Correct
yarn expo install expo-speech-recognition
yarn expo install react-native-pager-view @react-navigation/material-top-tabs

# Wrong — will resolve the latest npm version, which may be incompatible with the current Expo SDK
yarn add expo-speech-recognition

yarn expo install resolves the version compatible with the project's current Expo SDK (expo version in package.json). Using yarn add fetches the latest npm version, which may target a different SDK. The mismatch produces native module crashes at runtime (e.g. Cannot find native module 'ExpoSpeechRecognition') that survive multiple clean rebuilds — because the native binary is baked at build time, the only recovery is yarn expo install + a fresh dev client build.

This rule applies to any package in the Expo ecosystem: expo-*, react-native-* packages listed in the Expo SDK, and any package that ships a native module bundled with Expo.

TypeScript

  • Never use any — use proper types, generics, or type guards
  • Never use as assertions — fix types at the source
  • After any code change, run tsc --noEmit and fix all errors (zero errors is a baseline)
  • Named exports only — no export default (exception: Expo Router app/ files require it). One file = one component/hook; the file name and export name match 1:1
  • Always export at the bottom of the file — declare with const, export in a single export { } statement at the end; never inline export const
  • No React.FC — type props inline or with a separate type Props = {…}
  • Never import React as a default — import React from 'react' is not needed with the new JSX transform; import only what you use: import { useState, useEffect, useRef } from 'react'
  • Never use React.useState, React.useEffect, React.useCallback etc. — always use the named import directly
  • Arrow functions everywhere — const MyComponent = () => … and export const MyScreen = () => …; never function declarations for components, hooks, or helpers
  • Prefer union types over multiple boolean flags: type Status = "idle" | "loading" | "error" instead of isLoading + hasError
  • Type/interface names: no T or I prefix; self-documenting plain English; no abbreviations (except universally known ones like API, URL)
  • Helper function naming: get* / set* / create* for synchronous; fetch* / post* / patch* / delete* for API calls; is* / are* for type guards and predicates

tsconfig — baseUrl deprecation

TypeScript 6.x warns that baseUrl is deprecated (removed in 7.0). Do not remove baseUrl to silence it — Expo's Metro bundler reads baseUrl from tsconfig.json to resolve the project's path aliases (@/*, @screens, @atoms, …) at runtime. Dropping it can break the bundle even though tsc still passes (TS 4.1+ resolves paths without baseUrl, Metro does not).

Silence the warning by bumping ignoreDeprecations to match the version named in the warning:

{
  "compilerOptions": {
    "baseUrl": ".",
    "ignoreDeprecations": "6.0"
  }
}

This is a deferral, not a fix. baseUrl is fully removed in TS 7.0 — before adopting it, drop baseUrl, make every paths entry resolve relative to the tsconfig location, and confirm the Expo SDK in use no longer needs baseUrl for alias resolution.

Formatting

Prettier owns formatting — never hand-adjust quotes, semicolons, or wrapping. Run format-on-save (or prettier --write) and let it win. The project .prettierrc enforces:

  • Single quotes'foo', never "foo" (singleQuote: true). Prettier still emits double quotes inside JSX attributes; that is expected, leave it.
  • Always semicolons — terminate every statement (semi: true).
  • Trailing commas everywhere (trailingComma: "all"), 2-space indent (tabWidth: 2), 100-char width (printWidth: 100).
  • Ship a .prettierignore — exclude generated Lingui catalogs (src/i18n/locales/compiled/, src/i18n/locales/exported/), the Doppler env.template.yaml (its {{ .VAR }} syntax is invalid YAML), and dist/. Without it, prettier --check fails on files it cannot (or should not) format.
  • Formatting must be gated, not just configured. format:check (prettier --check .) runs in the pre-push hook next to check:tsc and lint, and again in the quality-checks CI workflow. A .prettierrc with no gate is exactly how a repo drifts from its own config.

CI / GitHub Actions

  • Run a quality-gate workflow (quality-checks.yml) on every PR and on pushes to long-lived branches (stg, main): check:tsc + lint + format:check, on source only (no Doppler/.env). It mirrors the pre-push hook so drift cannot slip through when hooks are bypassed (--no-verify) or uninstalled.
  • Corepack / Yarn-4 + setup-node trap: actions/setup-node@v5 defaults package-manager-cache: true, which runs yarn cache dir using the runner's global Yarn 1.x before corepack enable. Because package.json pins yarn@4.x via packageManager, global Yarn 1 refuses and the step fails. Every setup-node step in a Corepack/Yarn-4 project must set package-manager-cache: false.
  • When major-bumping a GitHub Action (or the Expo SDK), check the changed defaults, not just the inputs you pass — and verify with a real CI/build run before trusting it.

React / Components

  • Follow React best practices (hooks, memoization, clean component structure)
  • Never use eslint-disable-next-line react-hooks/exhaustive-deps — fix the dependency issue
  • React Compiler is on (reactCompiler: true) — write straightforward code and let it memoize. See the React Compiler section below before adding manual useRef/useCallback memoization or disabling a react-hooks rule
  • Keep files under 500 lines — split into sub-components, hooks, or utils proactively
  • Conditional rendering: use ternary (condition ? <X /> : null), not && — the && form renders 0 when condition is a falsy number
  • No margin on custom components — margins create invisible coupling between sibling layout. Use gap on the parent YStack/XStack, or padding on a container instead

React Compiler (reactCompiler: true)

The compiler auto-memoizes — prefer plain, direct code and let it optimize:

  • Drop manual memoization for closures. Don't mirror state into useRef or wrap callbacks in useCallback just to keep them stable or to read the latest value — the compiler memoizes the closure and it always sees current state. Write a plain function.
  • No forwardRef (React 19). Pass ref as an ordinary prop: ({ ref, ...props }: Props & { ref?: Ref<Handle> }), and drop the displayName boilerplate.
  • Prefer a clean fix over disabling a rule. When react-hooks/immutability flags a ref mutated during render, move the mutation into a useEffect keyed on its dependency. Only when there is genuinely no clean fix — Reanimated .value writes (stable shared refs mutated outside React's data flow), or one-time-init / command-signal effects — use a justified // eslint-disable-next-line react-hooks/immutability (or react-hooks/set-state-in-effect) with a comment explaining why. exhaustive-deps is still never disabled.

UI component import priority

Always resolve UI needs from the highest available source before reaching for lower ones:

  1. project-local — atoms, molecules, organisms in this project's own codebase (e.g. @atoms, @molecules, @organisms)
  2. @ksairi-org/ — shared org packages; covers buttons, touchables, images, screen containers, forms, auth
  3. tamagui — layout and text primitives: XStack, YStack, Text, Spinner, Stack, …
  4. react-native — only when no Tamagui or @ksairi-org/ equivalent exists
  5. Third-party libraries — last resort

Never import View, Text, TouchableOpacity, Pressable, or Image from react-native when a Tamagui or @ksairi-org/ wrapper covers the use case.

When to push a component to @ksairi-org/

Before adding a new component to the project-local layers, ask: would any other app on this stack benefit from this? If yes, and it has no app-specific tokens, data, or business logic, push it to @ksairi-org/libs instead and consume it remotely. Examples that belong upstream: generic wrappers around third-party primitives (KeyboardScrollView), shared layout primitives, utility hooks. This rule only applies when you are a member of the ksairi-org GitHub org and the consuming project already uses @ksairi-org/* packages.

Project-local component layers

Organize project-local shared components into three layers and place new components in the correct one:

  • atoms — single-purpose UI primitives with no business logic: typography wrappers, icon wrappers, basic input primitives, status badges, dividers, simple wrappers
  • molecules — multi-part units with a single concern, composed from atoms: form fields (label + input + error), card items, list rows, empty-state views, search bars, notification banners
  • organisms — full UI sections composing multiple molecules, may hold local state: forms, lists with loading/empty/data states, complex modals, navigation bars, onboarding steps

Screen containers (@ksairi-org/ui-containers)

Every screen must use Containers.Screen as its root element. It handles safe area insets automatically (via useSafeAreaInsets) so you never need SafeAreaView directly. react-navigation adjusts the inset context per navigator, so the all-edges default is self-correcting — no double-padding inside a Stack with a header or inside a Tabs screen.

import { Containers } from '@ksairi-org/ui-containers'

// Screen with its own ScrollView/KeyboardScrollView
<Containers.Screen shouldAutoResize={false}>
  <KeyboardScrollView>…</KeyboardScrollView>
</Containers.Screen>

// Screen without scroll — auto-resize switches to ScrollView if content overflows
<Containers.Screen>
  <Containers.SubY>…</Containers.SubY>
</Containers.Screen>
  • Containers.Screen — outermost; handles safe area, auto-resize to ScrollView when content overflows
  • Containers.SubY — vertical sub-section with standard horizontal padding ($md)
  • Containers.SubX — horizontal sub-section with standard horizontal padding ($md)
  • edges prop (default all four) — override only when you need to exclude specific edges
  • shouldAutoResize={false} — required when the screen already contains its own ScrollView

Buttons specifically@ksairi-org/ui-button takes priority over Tamagui's Button. Never use Tamagui's raw Button or react-native touchables for interactive buttons:

  • Primary full-width action with Tamagui theme tokens → BaseTouchable from @ksairi-org/ui-touchables with bg="$token"not CTAButton. CTAButton uses unstyled={true} on Tamagui's Button and the background prop does not reliably resolve theme tokens; BaseTouchable with bg does. Use opacity={disabled ? 0.4 : 1} for the disabled visual state and an inline {loading ? <Spinner /> : children} guard.
  • Primary full-width action with explicit hex/rgba colors → CTAButton (pass backgroundColor as a literal color string, not a $token; has loading prop and spinnerColor)
  • Secondary action → BasicButton (full ButtonProps pass-through, opacity=0.4 when disabled)
  • Text-only / link-style → GhostButton (transparent background, opacity=0.4 when disabled; pass color from your theme)
  • Icon-only → IconButton (circular, requires icon: ReactNode, full ButtonProps pass-through)
  • Custom layout or icons → BaseButton (accepts leftIcon/rightIcon)
  • Spring-animated with auto-width → SizingAnimatedButton from @ksairi-org/ui-button-animated (backgroundColor required; measures its own width internally)
  • Spring-animated with explicit width → AnimatedButton from @ksairi-org/ui-button-animated (backgroundColor and width: number both required; prefer SizingAnimatedButton unless you need explicit width control)

i18n (Lingui)

  • Use Trans + t for every hardcoded user-visible string
  • Use t`…` for prop strings (placeholders, aria labels, alert titles)
  • Import Trans, useLingui from @lingui/react/macro
  • Always commit src/i18n/locales/compiled/*.ts after running yarn i18n — the pre-build script reruns compilation; if the committed file differs from what the installed lingui version emits (e.g. lingui 6 adds /*eslint-disable*/), every build will produce a dirty tree
  • After upgrading @lingui/cli, run yarn i18n:compile and commit the result before the next build
  • Never use <Plural>, <Select>, or <SelectOrdinal> in Lingui v6 + Expo/Metro — these crash at runtime (TypeError: Cannot read property 'prototype' of undefined). Use <Trans> with a ternary: {count === 1 ? <Trans>1 item</Trans> : <Trans>{count} items</Trans>}. See docs/solutions/runtime-errors/lingui-v6-plural-macro-metro.md.
  • Never install @lingui/macro in a v6 project — it max-publishes at v5.9.5 and pulls duplicate v5 core/react packages. Use @lingui/react/macro which ships with v6.

Adding a new locale

Four files to touch — missing any one of them silently falls back to English:

  1. src/i18n/config/constants.ts — add { code: 'Display name' } entry to locales
  2. src/i18n/locales/exported/<code>/screens.po — create the PO file with translations (copy structure from an existing locale)
  3. src/i18n/locales/compiled/<code>.ts — create the compiled catalog (same hashed message-ID keys as every other locale; copy from an existing compiled file and replace the translation strings)
  4. src/i18n/utils.ts — import the compiled messages and add to messagesByLocale

iOS second-launch crash (-[NSTaggedPointerString count])

Settings.set({ AppleLanguages: locale }) writes a bare string to NSUserDefaults. On the next launch, UIKit's _CFLocaleCreateLocaleIdentifierForAvailableLocalizations reads AppleLanguages expecting an NSArray and calls count on the string — crashing at ~100ms before any JS initializes. The stack points entirely at system frameworks, making the culprit invisible without reading the crash JSON.

Fix: always wrap in an array — Settings.set({ AppleLanguages: [locale] }).

The crash only manifests on the second launch because the bad value is written on the first. Clearing it requires deleting the app (wipes NSUserDefaults), then reinstalling.

RTL languages (Arabic, Hebrew, …)

When locale is detected from the device language at startup (the standard pattern using expo-localization), RTL is free: React Native reads I18nManager.isRTL from the OS before the first render and mirrors all flex layouts automatically. You do not need to call I18nManager.forceRTL.

forceRTL is only needed when the app has an in-app language switcher that can flip direction mid-session. In that case, the call must be followed by a full app restart (RNRestart or Updates.reloadAsync) because isRTL is read once at process start.

Before enabling an RTL locale, audit layouts for direction-unsafe styles:

grep -r "left:\|right:\|paddingLeft\|paddingRight\|marginLeft\|marginRight\|textAlign.*left\|textAlign.*right" src/screens --include="*.tsx"

hitSlop left/right are safe (touch area, not layout). Anything else must be replaced with start/end equivalents or removed.

General

  • No over-engineering; no magic numbers — extract to named constants
  • One import statement per module path (prevents import/no-duplicates)

Tamagui

  • Main config: src/theme/tamagui.config.ts; themes: src/theme/themes.ts
  • Color tokens use semantic kebab-case names — always check themes.ts before using. Standard scale: $surface-app, $surface-card, $surface-subtle, $surface-hover, $surface-pressed, $border-subtle, $border-default, $text-disabled, $text-placeholder, $text-tertiary, $text-secondary, $text-emphasis
  • allowedStyleValues: "strict" — only token values; raw hex/rgba will error at compile time
  • Spacing/sizing: $sm, $md, $lg from sizesSpaces; radius from radius tokens
  • Use gap on XStack/YStack for whitespace between elements — not Separator. Separator renders a visible divider line and should only be used when that line is intentional UI. Using Separator purely for spacing conflates layout with visual chrome.
  • Never use Tamagui color props with raw strings
  • Typography: use components from src/components/ — no raw <Text> with style props
  • Never use StyleSheet.create() — use Tamagui styled(). If a third-party component's API forces a plain style object (e.g. a style prop that only accepts StyleSheet output), add a // NOTE: StyleSheet required — <reason> comment and surface it to the user so they can decide whether to accept it.
  • Never add inline style props to non-Tamagui components — wrap with styled() from @tamagui/core first, then use token-based style props on the wrapper
  • Never use absolute positioning for layout — it breaks safe area handling and adaptive sizing. Only use position: absolute for overlays (badges, toasts, FABs) that genuinely need to float above the document flow

Tamagui theme config rules (avoid these two mistakes)

Never put theme values in createTokenscolor: themes.light in createTokens locks every $color* reference to the light value regardless of active theme, breaking dark mode. Color tokens belong only in the themes object; createTokens should only contain spacing, sizing, radius, and font tokens.

Never use as type assertions on theme objects} as typeof defaultConfig.themes.light erases your custom token names from TypeScript's view, so $surface-card etc. won't typecheck. Remove the assertion and let TypeScript infer the full type — createTamagui propagates it automatically to GetThemeValueForKey.

Zustand — state ownership

Layer Owner
Server state react-query / orval hooks
Client/UI state Zustand

If data comes from the backend it belongs in react-query. Zustand stores should be thin.

Client-driven-by-remote pattern (edit flows)

When the user edits remote data (profile, settings, checkout form), the handoff pattern is Fetch → Hydrate local state → Edit → Save:

Use react-query to fetch, then seed a local Zustand slice or useState with the result. The user edits the local copy; on save, call the mutation and invalidate the query. Never mutate the react-query cache directly for optimistic edits unless you have a specific reason — local state is simpler and easier to reason about.

Store pattern

type MyStore = MyStoreState & MyStoreFunctions;
const INITIAL_STATE: MyStoreState = { ... };
const useMyStore = create<MyStore>()(
  persist(
    (set) => ({
      ...INITIAL_STATE,
      setKeyValue: (key, value) => set((state) => ({ ...state, [key]: value })),
    }),
    { name: "my-storage", storage: createJSONStorage(() => createZustandMmkvStorage({ id: "my-storage" })) },
  ),
);

Selectors — always select minimally

// Good
const firstName = useUserStore((state) => state.firstName);
// Bad — re-renders on any store change
const store = useUserStore();

Tab Navigation Setup

Every app with a tab navigator must install react-native-pager-view and @react-navigation/material-top-tabs before the first native build (the pager and the precise types still come from these packages):

yarn expo install react-native-pager-view @react-navigation/material-top-tabs

SDK 56+: never import @react-navigation/* values directly — expo-router forbids it and Metro throws at bundle time. Import the navigator value from expo-router's re-export and keep precise types via a type-only import (erased at build, so it doesn't trip the runtime check):

import { createMaterialTopTabNavigator } from 'expo-router/js-top-tabs';
import { withLayoutContext } from 'expo-router';
import type { MaterialTopTabBarProps } from '@react-navigation/material-top-tabs';

const { Navigator } = createMaterialTopTabNavigator();
const MaterialTopTabs = withLayoutContext(Navigator);

Full re-export mapping: @react-navigation/nativeexpo-router/react-navigation (ThemeProvider, DefaultTheme, DarkTheme); @react-navigation/bottom-tabsexpo-router/js-tabs; @react-navigation/elementsexpo-router/react-navigation (PlatformPressable).

Use MaterialTopTabs in app/(tabs)/_layout.tsx for animated carousel swipe between tabs. Installing after the first build requires a full EAS rebuild — do it upfront.

Resetting UI state on tab blur

Any open or expanded UI state — swipeables, accordions, bottom sheets, inline menus — must be reset when a tab loses focus. With a pager navigator, the screen stays mounted, so stale open state is visible when the user swipes back.

Reset in the useFocusEffect cleanup, and use an instant reset (not animated) to avoid a visible flash during the pager's return transition:

import { useCallback, useEffect, useState } from 'react'

const [closeKey, setCloseKey] = useState(0)

useFocusEffect(
  useCallback(() => {
    return () => setCloseKey(k => k + 1) // fires on blur, not on focus
  }, [])
)

// In the child component:
useEffect(() => {
  if (closeKey > 0) ref.current?.reset() // reset(), not close() — instant, no animation
}, [closeKey])

close() runs an animation that can still be seen as the pager slides the screen back in. reset() snaps instantly while the screen is off-screen.

List entrance animations (pager screens)

With a pager navigator, screens stay mounted — a useEffect([]) inside a list item fires once at app start for all tabs simultaneously, not when the user first visits the tab. Gate the animation on first focus instead:

const [animKey, setAnimKey] = useState(0)
const hasAnimated = useRef(false)

useFocusEffect(
  useCallback(() => {
    if (!hasAnimated.current) {
      hasAnimated.current = true
      setAnimKey(1) // triggers animation exactly once
    }
  }, [])
)

// Pass animKey down to each animated list item:
const AnimatedEntry = ({ index, animKey, children }) => {
  const tx = useSharedValue(index % 2 === 0 ? -40 : 40)
  const opacity = useSharedValue(0)

  useEffect(() => {
    cancelAnimation(tx)
    cancelAnimation(opacity)
    tx.value = index % 2 === 0 ? -40 : 40
    opacity.value = 0
    tx.value = withDelay(index * 100, withTiming(0, { duration: 500 }))
    opacity.value = withDelay(index * 100, withTiming(1, { duration: 500 }))
  }, [animKey])
  // ...
}

cancelAnimation before resetting the shared value is required — without it, a re-trigger while the previous animation is still running produces a jump.

Settings Tab

Every app with a tab navigator must include a Settings tab. It is the standard home for account-level and device-level controls that don't belong in content screens.

Required items (always present):

  • Subscription — shows current plan (Free / Pro Monthly / Pro Annual) with spinner while RC loads; "Upgrade to Pro ✦" button when free → presentPaywall(); "Manage subscription" when pro → Purchases.showManageSubscriptions(). See /expo-rn-plugin:iap for the full pattern.
  • Push notification permission — shows Granted / Denied status; when denied, tapping opens Linking.openSettings() (app-specific settings page on both platforms); uses an AppState listener + openedSettings ref to re-check the permission when the user returns. See /notifications for the full pattern.
  • Sign out — always confirm with Alert.alert before calling supabase.auth.signOut(); use style: 'destructive' on the confirm button; placed at the bottom of the screen so it doesn't get tapped by accident

Growing list — as new general-purpose controls are identified (e.g. language preference, theme toggle, account deletion), add them here before adding to any content screen.

Do not put permission or account controls in content screens (journal, feed, reflections, etc.) — they belong in Settings.

Environment Badge

Every app must render an EnvBadge in the root layout. It reads EXPO_PUBLIC_ENV and shows an amber "STAGING" pill overlay in the top-right corner when the env is not prd. It renders nothing in production, so there is no cost to keeping it in the tree.

Mount it as a sibling of <SplashView /> in app/_layout.tsx:

import { EnvBadge } from "@atoms";

// Inside RootLayout return — EnvBadge must come before SplashView so it renders on top once the splash fades:
<EnvBadge />
<SplashView ... />

The component lives at src/components/atoms/EnvBadge/index.tsx and is exported from @atoms.

Env Vars / Doppler

  • Secrets via Doppler — project mobile, configs dev / stg / prd
  • Adding a new secret requires three steps:
    1. Add to env.template.yaml: EXPO_PUBLIC_FOO={{ .FOO }} (left = shell var, right = Doppler key)
    2. Set in relevant configs: doppler secrets set FOO="value" --project mobile --config stg
    3. Sync: yarn sync-env-vars
  • Doppler key naming: never put EXPO_PUBLIC_ on the Doppler key — that prefix belongs only on the left side of env.template.yaml. Example: Doppler key is SUPABASE_API_KEY; template maps it as EXPO_PUBLIC_SUPABASE_API_KEY={{ .SUPABASE_API_KEY }}.

HTTP / API

  • Use orval-generated hooks for all API calls — never axios directly in components
  • axios only for non-REST endpoints or one-off authenticated file uploads
  • Debugging stale queries: open the React Query panel in Expo dev client (@dev-plugins/react-query) before touching code

Lists

  • Always use FlashList from @shopify/flash-list — never FlatList
  • estimatedItemSize is required — omitting it causes a warning and degrades performance

User Feedback (toasts)

  • Use burnt for all success and error toasts — never Alert.alert for transient feedback
  • Success: Burnt.toast({ title: '…', preset: 'done' }) — auto-dismisses after ~2s
  • Error: Burnt.toast({ title: '…', preset: 'error' }) — auto-dismisses after ~4s
  • Destructive / irreversible actions → confirmation Alert.alert dialog, not a toast
  • Form validation errors → inline under the field, not a toast

Foreground push notification UI

When a push arrives while the app is open, show a top banner with a bell icon — not Burnt.alert (centered modal with heart). The notification helper in useToast encapsulates this:

// useToast.ts
function notification({ title, message, duration = 4 }) {
  Burnt.toast({
    title, message, preset: 'custom',
    icon: { ios: { name: 'bell.fill', color: '#007AFF' } },
    duration, from: 'top',
  })
}

Wire it up in the root layout:

const { notification } = useToast()
useEffect(() => {
  return subscribeToForegroundMessages((title, body) => {
    notification({ title, message: body })
  })
}, [])

Burnt.alert with preset: 'heart' is for app-initiated alerts (achievements, confirmations). The top-banner notification pattern matches what users expect from a real push notification landing while they're in the app.

Dates

  • Format dates with date-fns — always pass the locale from expo-localization for locale-aware output
  • Never use Date.toLocaleDateString() — output varies by device locale settings

OTA Updates

  • OTA updates only work if no native code changed since the last full EAS build. The most common cause: a dependency update that includes native modules. When in doubt, do a full build.
  • Control update urgency via the ['expo-updates'].type field in app.config.ts:
    • 'mandatory' — shows an alert on launch requiring the user to update before continuing
    • 'optional' — silently applies the update on the next cold start (app fully closed and reopened)
  • OTA updates do not work on debug builds — test against a release (internal) build on a simulator or device.

Assets

  • Compress all PNG/JPG/MP4 assets with ImageOptim before committing — typically saves 40–80% with no perceptible quality loss
  • To find all images in the project: find . -name "*.png" | grep -v node_modules | xargs -I {} cp {} tmp-images

Supabase custom schema setup

When using a custom schema (e.g. api instead of public), two steps are required before the app can query it:

1. Expose the schema in PostgREST — Supabase only exposes public and graphql_public by default. Add your schema via the Management API or the dashboard:

# Via Management API (scriptable — use in setup automation)
curl -X PATCH "https://api.supabase.com/v1/projects/{project_ref}/postgrest" \
  -H "Authorization: Bearer {supabase_access_token}" \
  -H "Content-Type: application/json" \
  -d '{"db_schema":"public,graphql_public,api"}'

# Or: Supabase dashboard → Settings → API → Exposed schemas → add "api"

Without this, every query returns PGRST106: Invalid schema.

2. Grant privileges to roles — Supabase no longer auto-grants privileges to anon/authenticated/service_role on new tables (effective 2026-05-30 for new projects, 2026-10-30 for existing). Every migration must include explicit grants:

GRANT SELECT, INSERT, UPDATE, DELETE ON api.your_table TO authenticated;
GRANT ALL ON api.your_table TO service_role;
-- anon typically gets no access to app tables (requires auth)

Without this, authenticated users get 42501: permission denied for table.

The Supabase client must also declare the schema:

createClient(url, key, { db: { schema: 'api' } })

Push Notifications

Simulator-only guard for notification UI

Features that require a physical device (FCM tokens, permission requests, local notification scheduling) should not be visually disabled on the simulator. Instead, keep the UI fully enabled and show a toast when tapped. This gives a realistic preview of the UI without misleading "Denied" states.

import * as Device from 'expo-device'

const isSimulator = !Device.isDevice

function showSimulatorToast() {
  alert({ title: 'Physical device only', message: 'This feature is not available on the simulator.', preset: 'error', duration: 3 })
}

// In handlers:
async function handleReminderToggle() {
  if (isSimulator) { showSimulatorToast(); return }
  // ... real logic
}

In JSX, replace disabled={!notifPermission} with disabled={isSimulator ? false : !notifPermission} and the same pattern for opacity and backgroundColor. Skip "Enable notifications in Settings" hint copy on simulator ({!isSimulator && !notifPermission && ...}).

The service layer (requestNotificationPermission, getFCMToken) already returns false/null for !Device.isDevice — no changes needed there.

Device token registration

Register the FCM token whenever the auth state changes to signed-in. Fire-and-forget — do not await in the auth listener:

// in useAuthSession — onAuthStateChange handler
if (s?.user) {
  identifyRevenueCatUser(s.user.id)
  upsertDeviceToken(s.user.id) // fire and forget
}

device_tokens table schema

One row per user — unique(user_id). If the user reinstalls the app they get a new FCM token; upserting on user_id updates the row in place rather than accumulating stale tokens.

create table api.device_tokens (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  fcm_token text not null,
  reminder_hour int,        -- local time (null = no reminder)
  reminder_minute int,
  timezone text,            -- IANA string e.g. "America/New_York"
  updated_at timestamptz not null default now(),
  unique (user_id)
);
alter table api.device_tokens enable row level security;
grant select, insert, update, delete on api.device_tokens to authenticated;
grant select, insert, update, delete on api.device_tokens to service_role;
create policy "Users can manage their own device tokens"
  on api.device_tokens for all
  using (auth.uid() = user_id) with check (auth.uid() = user_id);

Permission status vs permission request

Never call requestPermissionsAsync() (or requestNotificationPermission()) on screen mount. Tab screens are pre-mounted by the tab navigator — a useEffect on the settings tab fires at app startup, which would show the OS permission dialog before the user has even opened settings. That is too aggressive.

Instead, keep two separate functions:

  • getNotificationPermissionStatus() — read-only, never shows a dialog
  • requestNotificationPermission() — shows the OS dialog (call only on explicit user action)
export type NotificationPermissionStatus = 'undetermined' | 'granted' | 'denied'

export async function getNotificationPermissionStatus(): Promise<NotificationPermissionStatus> {
  if (!Device.isDevice) return 'denied'
  const { status } = await ExpoNotifications.getPermissionsAsync()
  if (status === 'granted') return 'granted'
  if (status === 'undetermined') return 'undetermined'
  return 'denied'
}

In the settings screen, track NotificationPermissionStatus | null (null = loading):

const [notifPermission, setNotifPermission] = useState<NotificationPermissionStatus | null>(null)

async function refreshPermissionStatus() {
  if (isSimulator) return
  const status = await getNotificationPermissionStatus()
  setNotifPermission(status)
  if (status === 'granted') {
    const token = await getFCMToken()
    const { data: { user } } = await supabase.auth.getUser()
    if (user) upsertDeviceToken(user.id)
  }
}

The permission row tap handler covers all three states:

async function handlePermissionPress() {
  if (notifPermission === 'granted') return
  if (notifPermission === 'undetermined') {
    // First time — show in-app OS dialog
    const granted = await requestNotificationPermission()
    setNotifPermission(granted ? 'granted' : 'denied')
    if (granted) { /* fetch token + upsert */ }
    return
  }
  // Denied — must go to Settings
  openedSettings.current = true
  await Linking.openSettings()
}

Show label and color per state:

  • 'undetermined' → accent color, label "Enable"
  • 'granted' → green, label "Granted"
  • 'denied' → red, label "Denied — tap to open Settings"

Re-enabling notifications after denial

When a user denies permission then later re-enables it from iOS/Android Settings, the app must detect the return and register the FCM token immediately. Pattern: AppState listener + openedSettings ref:

const openedSettings = useRef(false)

useEffect(() => {
  refreshPermissionStatus()
  const sub = AppState.addEventListener('change', (state) => {
    if (state === 'active' && openedSettings.current) {
      openedSettings.current = false
      refreshPermissionStatus() // re-check + upsert if newly granted
    }
  })
  return () => sub.remove()
}, [])

Notification permissions do NOT belong in the backend

The OS silently drops notifications to users who have denied permission — the backend never needs to know. Store only fcm_token, reminder_hour, reminder_minute, and timezone. When the user disables a reminder in-app, set those three fields to null; the edge function skips rows with no reminder set.

Syncing reminder preferences

Always include timezone (IANA string) when syncing reminder time so the server can fire at the correct local time regardless of UTC offset. Use Intl.DateTimeFormat().resolvedOptions().timeZone on the device:

const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
await supabase.from('device_tokens').upsert(
  { user_id, fcm_token, reminder_hour: enabled ? hour : null,
    reminder_minute: enabled ? minute : null,
    timezone: enabled ? timezone : null, updated_at: new Date().toISOString() },
  { onConflict: 'user_id' },
)

Supabase deployment rule

Always apply every server-side fix — edge functions, migrations, RLS policies, DB schema changes — to both stg AND prd. Client code is shared and deployed once, but server-side changes are per-project. Never leave one Supabase project behind. Use the project's npm scripts:

yarn functions:deploy:stg   # or the equivalent for migrations
yarn functions:deploy:prd

If only stg is fixed, bugs will be impossible to reproduce in production and users will be affected while staging appears healthy.

Supabase Edge Functions

tsconfig exclusion

Edge functions are Deno — exclude them from the React Native tsconfig or tsc will fail on Deno globals and https://esm.sh/ imports:

"exclude": ["node_modules", "supabase/functions"]

Excluding functions from the OpenAPI spec

The @ksairi-org/react-query-sdk spec generator scans every subdirectory of supabase/functions/ and generates OpenAPI 3.0 entries for each. When merged with the Supabase REST spec (Swagger 2.0), the version mismatch causes orval validation to fail.

Every edge function must start with // @openapi-internal unless it is explicitly a typed client API endpoint that should appear in the generated React Query SDK. In practice, all functions in this project use // @openapi-internal — cron jobs, webhooks, admin tools, push senders, device-facing custom protocols (e.g. OTA manifest), and dev helpers all qualify.

// @openapi-internal — <one-line description of what this function does>
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

The generator skips any index.ts that contains // @openapi-internal. Forgetting this line causes yarn start to fail with an orval OpenAPI validation error.

Deploying without Docker

supabase functions deploy requires Docker only for local serving. Deploying to the hosted project works without it:

supabase login --token $SUPABASE_ACCESS_TOKEN
supabase link --project-ref <ref>
supabase functions deploy send-reminders --project-ref <ref>

pg_cron for scheduled push

Set up via SQL in the Supabase dashboard (requires pg_cron and pg_net extensions, both enabled by default):

select cron.schedule(
  'send-reminders',
  '* * * * *',
  $$select net.http_post(
    url := 'https://<ref>.supabase.co/functions/v1/send-reminders',
    headers := '{"Authorization": "Bearer <service_role_key>"}'::jsonb
  ) as request_id;$$
);

Firebase service account key

The FCM v1 API requires a service account private key to send push notifications programmatically. This is different from what the Firebase Console UI uses — the console authenticates you as a human via Google login, but a backend function needs its own credential to call the FCM HTTP API on your behalf.

To obtain it: Firebase Console → Project Settings → Service Accounts → Generate new private key. This downloads a JSON file containing client_email and private_key.

Store those two values as Supabase secrets (FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY) and delete the JSON from your filesystem immediately. Do not revoke the key once it is in Supabase secrets — the edge function depends on it. If you suspect it is compromised, rotate it: generate a new key, update the Supabase secrets, then revoke the old one.

One service account JSON covers all Supabase environments (stg, prd) that point to the same Firebase project.

FCM v1 auth from Deno

Use jose to sign the JWT assertion and exchange for an OAuth2 access token. Store FIREBASE_PROJECT_ID, FIREBASE_CLIENT_EMAIL, and FIREBASE_PRIVATE_KEY as Supabase secrets:

import * as jose from 'https://esm.sh/jose@5'

const privateKey = await jose.importPKCS8(FIREBASE_PRIVATE_KEY.replace(/\\n/g, '\n'), 'RS256')
const jwt = await new jose.SignJWT({ scope: 'https://www.googleapis.com/auth/firebase.messaging' })
  .setProtectedHeader({ alg: 'RS256' })
  .setIssuedAt().setExpirationTime(Math.floor(Date.now() / 1000) + 3600)
  .setIssuer(FIREBASE_CLIENT_EMAIL)
  .setAudience('https://oauth2.googleapis.com/token')
  .sign(privateKey)

const { access_token } = await fetch('https://oauth2.googleapis.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: jwt }),
}).then(r => r.json())

Then POST to https://fcm.googleapis.com/v1/projects/${FIREBASE_PROJECT_ID}/messages:send with Authorization: Bearer <access_token>.

JWT grant type — oauth not oauth2

The correct grant type for a service account JWT assertion is urn:ietf:params:oauth:grant-type:jwt-bearer. Using oauth2 (with the 2) causes Google's token endpoint to return unsupported_grant_type. This applies to both Deno (URLSearchParams) and Node (fetch).

APNs credentials required for iOS delivery

Even with a valid service account and OAuth token, FCM returns 401 UNAUTHENTICATED / THIRD_PARTY_AUTH_ERROR if the APNs Auth Key is not configured. This has nothing to do with your service account — it means Firebase can't forward the notification to Apple.

Fix: Firebase Console → Project Settings → Cloud Messaging → Apple app configuration → upload your .p8 APNs Auth Key (from Apple Developer → Keys). One key works for both development and production.

THIRD_PARTY_AUTH_ERROR in the FCM response details array is the diagnostic signal — the generic 401 message says nothing useful.

send_test_push MCP tool — prerequisites checklist

Before calling send_test_push:

  1. firebase-service-account.json exists in the project root (path set in mcp.config.jsonfirebase.serviceAccountPath)
  2. firebase-service-account.json is in .gitignore — the default pattern *firebase-adminsdk*.json does not match a file named firebase-service-account.json; add an explicit entry
  3. APNs Auth Key is uploaded in Firebase Console (see above)

inspect_push_tokens MCP tool — schema flexibility

The tool introspects information_schema.columns before querying so it handles non-standard schemas:

  • Token column: token or fcm_token
  • Timestamp column: created_at or updated_at
  • platform column may be absent (omitted from SELECT if missing)

If you extend the device_tokens table, no changes to the tool are needed as long as id, user_id, and at least one of the token column names above are present.

Timezone-aware reminder matching in edge functions

Use Intl.DateTimeFormat to convert UTC now to the user's local time — do not store UTC offsets (they break across DST transitions):

function matchesReminderTime(now: Date, timezone: string, hour: number, minute: number): boolean {
  const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, hour: 'numeric', minute: 'numeric', hour12: false })
    .formatToParts(now)
  const localHour = parseInt(parts.find(p => p.type === 'hour')!.value)
  const localMinute = parseInt(parts.find(p => p.type === 'minute')!.value)
  return localHour === hour && localMinute === minute
}

Streaks

Anchor the streak count to yesterday if today has no entry yet, so users see their active streak before writing each day — not 0:

const cursor = new Date()
if (!daysWithEntry.has(dayKey(cursor))) {
  cursor.setDate(cursor.getDate() - 1) // still within today's window
}
while (daysWithEntry.has(dayKey(cursor))) {
  streak++
  cursor.setDate(cursor.getDate() - 1)
}

Only reset to 0 if neither today nor yesterday has an entry.

Daily rotating content

For prompts, tips, or any content that should change daily but stay stable throughout the day:

export function getDailyPromptIndex(promptCount: number): number {
  return Math.floor(Date.now() / 86400000) % promptCount
}

Place the translatable prompt strings in the screen file (not in the hook) so Lingui's extractor picks them up for the correct catalog.

Dev-only tools (__DEV__)

__DEV__ is true only when Metro bundler is running (dev client / yarn start). It is false in all EAS builds — including staging and production. Use it to gate developer-only UI that should never ship to users:

{__DEV__ && fcmToken ? (
  <SizingAnimatedButton onPress={handleFcmTest} ...>
    <LabelLg>Send real FCM push (dev)</LabelLg>
  </SizingAnimatedButton>
) : null}

Typical uses: FCM pipeline test buttons, RC customer ID display, internal debug panels. Never use __DEV__ to gate features that should exist in staging but not production — use EXPO_PUBLIC_ENV for that.

Dev-only edge functions

For dev-only backend tools (e.g. send-test-push), still deploy to all environments (stg + prd). The app only calls them from __DEV__ builds, so they're harmless in production but don't need special environment checks.

Mark them // @openapi-internal (required for all edge functions — see above).

i18n — dev-only strings

Do not wrap dev-only strings in a t template literal or <Trans> — Lingui will extract them and warn about missing translations in every language. Use a plain string literal instead:

// ✅ plain string — Lingui ignores it, no translations needed
'Not available on simulator'

// ❌ extracted by Lingui — requires translations in all 5 languages
t`Not available on simulator`

The exception is strings that appear in __DEV__ blocks but are also shown to real users in some flows — those still need t\``.

For truly unavoidable cases where a dev string ends up extracted (e.g. inside a shared component), copy the English value as-is into all non-English .po files to silence the warning.

Build scripts — naming convention

Script Platform Profile Target
dev-client-ios iOS development Simulator
dev-client-android Android development Emulator
dev-client-ios-device iOS development-device Physical device
dev-client-android-device Android development-device Physical device
dev-client-ios:prd / dev-client-android:prd etc. Same, prd env

Required eas.json profiles:

"development": {
  "developmentClient": true,
  "distribution": "internal",
  "ios": { "simulator": true }
},
"development-device": {
  "developmentClient": true,
  "distribution": "internal",
  "ios": { "simulator": false }
},
"preview": {
  "distribution": "internal",
  "ios": { "simulator": false }
}
  • development / development-devicedevelopmentClient: true, shows Expo dev launcher, connects to Metro over Wi-Fi or USB
  • preview → standalone build, JS bundled at build time, no Metro — use for distribution testing (TestFlight-like), not for iterative development on device
  • Do not use preview for device dev clients — the app will open once (from the embedded bundle) but has no Metro fallback and will crash on subsequent opens if the bundle has any issue

For Android, the ios.simulator flag is ignored — development would work for physical Android devices too, but use development-device for consistency.

Required eas.json submit profiles:

"submit": {
  "stg": {
    "ios": {
      "appleId": "you@example.com",
      "ascAppId": "<stg App Store Connect app ID>"
    },
    "android": {
      "serviceAccountKeyPath": "./eas-service-account.json",
      "track": "internal",
      "releaseStatus": "completed"
    }
  },
  "prd": {
    "ios": {
      "appleId": "you@example.com",
      "ascAppId": "<prd App Store Connect app ID>"
    },
    "android": {
      "serviceAccountKeyPath": "./eas-service-account.json",
      "track": "production",
      "releaseStatus": "draft"
    }
  }
}
  • stg uses releaseStatus: "completed" — without it, the AAB is uploaded to the artifact library but no release is created, requiring a manual step in the Play Console.
  • prd uses releaseStatus: "draft" — production releases should be manually reviewed in the Play Console before going live (set rollout %, check release notes, confirm everything).

@ksairi-org library publishing

@ksairi-org/* packages live in the ksairi-libs monorepo. CI publishes automatically on push to main — never publish manually unless the CI workflow is broken.

Workflow for fixing a library bug:

  1. Patch node_modules locally to validate the fix in the consuming app
  2. Apply the same fix to the library source in ksairi-libs
  3. Bump the version in package.json
  4. Push to main — CI publishes and the fix is live

Do not use yarn patch (Yarn Berry's patch mechanism) for @ksairi-org/* packages — that's for third-party libraries you don't control. Fix the source instead.

npm auth token — store in ~/.yarnrc.yml (global, never committed):

npmScopes:
  "@ksairi-org":
    npmRegistryServer: "https://registry.npmjs.org"
    npmAuthToken: npm_xxx

Also add to ~/.npmrc for plain npm publish fallback:

//registry.npmjs.org/:_authToken=npm_xxx

Never put the token in the repo's .yarnrc.yml.

.gitignore

Always include these entries for Expo/EAS projects:

# EAS local build artifacts
*.ipa
*.aab
*.apk
*.app
build-*.tar.gz

# Native credential files
*.jks
*.p8
*.p12
*.key
*.mobileprovision

# Firebase service account keys (admin privileges — never commit)
*firebase-adminsdk*.json
# Google Play service account key
eas-service-account.json

Build artifacts (.ipa, .aab, .apk) are produced by eas build --local and can be several hundred MB. They must never be committed.

Unit / Component Tests

  • Test runner: jest-expo; render helper: @testing-library/react-native
  • Always wrap renders in a renderWithProviders helper that includes Tamagui, query client, and i18n providers
  • Assert on what the user sees (screen.getByText, screen.getByRole) — never on internal state
  • Run /expo-rn-plugin:testing for canonical test patterns and provider setup
Install via CLI
npx skills add https://github.com/ksairi-org/claude --skill coding-standards
Repository Details
star Stars 0
call_split Forks 0
navigation Branch main
article Path SKILL.md
More from Creator