Claude Skill

react-code

Patterns and conventions for writing and editing React code, including components and hooks. Use this skill whenever writing or reviewing React components, hooks (useEffect, useCallback, useState), event handlers, or component extraction decisions. Also trigger when debugging sta

LLM Mart · 0 points · 14 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download gaia-react-gaia-.claude_skills_react-code-6bb0226.zip · 13 KB
Part of gaia-react/gaia — 26 skills

Install

skills CLI npx skills add https://github.com/gaia-react/gaia/tree/main/.claude/skills/react-code
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install gaia-react-gaia@llmmart
Git git clone https://github.com/gaia-react/gaia.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole gaia-react/gaia collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

React Code

Write and edit React components, pages, routes, hooks, and forms following project conventions.

Reach for the Platform First

Before installing a package or hand-rolling a primitive, walk this ladder and stop at the first hit:

  1. Existing GAIA code, a component, hook, or util already covers it (form inputs → Gate 2).
  2. Web platform, a browser API or native element does the job: Intl (dates, numbers, lists, plurals), URL / URLSearchParams, crypto.randomUUID(), structuredClone(), AbortController, native Array / Object methods, <dialog>, modern CSS (:has(), container queries).
  3. Already-installed dependency, check package.json before adding a sibling that does the same job. For component/hook traps Claude often hand-rolls (client-only/useHydrated, sse, debounce-fetcher), see the remix-utils decision map at wiki/dependencies/remix-utils.md before reinventing.
  4. New dependency, only when 1-3 genuinely fall short; the added weight has to earn its place.
  5. Custom code, last resort, kept minimal.

The largest real savings come from Intl over date/number-formatting libraries and native collection methods over lodash/underscore (already enforced by you-dont-need-lodash-underscore). Reaching for the platform replaces a needless dependency or bespoke widget; it never overrides accessibility, input validation, or an existing GAIA component (a wrapper exists for a reason).

Pre-Flight Gates

Most hook bugs come from misidentifying the type of problem being solved. Before writing or editing hooks, run through these gate, it only applies when the relevant pattern is present in your changes.

Gate 1: Hook Check

Before writing useEffect:

  1. Can I calculate this during render? → Derive inline or useMemo, no Effect needed.
  2. Does this respond to a user action? → Put it in the event handler, no Effect needed.
  3. Am I syncing state to other state? → Derive it; remove the redundant state, no Effect needed.
  4. Am I notifying a parent of a state change? → Call both setters in the handler, no Effect needed.
  5. Do I need to reset child state when a prop changes? → Use key, no Effect needed.
  6. Am I synchronizing with an external system (browser API, third-party widget, network)? → Effect is appropriate here. Add cleanup. For data fetching, include an ignore flag.

Before writing useCallback:

Only use when the function is:

  1. Passed as a prop to a memo-wrapped component
  2. A dependency of useEffect, useMemo, or another useCallback
  3. Passed to a child that uses it in a hook dependency array

If none apply, skip useCallback, it adds indirection without benefit.

useState type inference: Omit explicit type when inferable from the default value. Add types for unions or complex objects. For an absent initial value, prefer undefined over null (GAIA never-null): useState<T>() is already typed T | undefined.

Gate 2: Form Element Check

Before writing <input>, <select>, <textarea>, or <input type="checkbox">:

Native element Use instead
<input type="text"> InputText (~/components/Form/InputText)
<input type="email"> InputEmail (~/components/Form/InputEmail)
<input type="password"> InputPassword (~/components/Form/InputPassword)
<input type="checkbox"> (single) Checkbox (~/components/Form/Checkbox)
<input type="checkbox"> (group) Checkboxes (~/components/Form/Checkboxes), needs options: Option[]
<input type="radio"> / radio group RadioButtons (~/components/Form/RadioButtons), needs options: Option[]
<select> Select (~/components/Form/Select), needs name + options: SelectOption[]
<textarea> TextArea (~/components/Form/TextArea), needs name; auto-resizes
Date (year/month/day) YearMonthDay (~/components/Form/YearMonthDay)
Field with label + error + description Field (~/components/Form/Field)

Exceptions (native OK): <input type="hidden">, <input type="file">, <input type="range">.

Select requires options: SelectOption[] ({label, value}). Build this array (with useMemo if derived from translations/data) rather than inline <option> elements.

CRITICAL, @conform-to/zod: Always import from /v4 subpath. The default export targets Zod v3 and causes a runtime error that typecheck/lint/build do NOT catch.

// BAD, runtime error
import {parseWithZod} from '@conform-to/zod';
// GOOD
import {parseWithZod} from '@conform-to/zod/v4';

See references/conform-forms.md for full Conform + Zod wiring. Beyond the import path, all Zod schemas use Zod 4 syntax, the typescript skill's references/zod.md is the canonical Zod 3 → Zod 4 migration map (z.strictObject, top-level string formats, etc.).

Gate 3: Translation Check

Before writing ANY user-visible string in JSX:

Every string a user can see or hear, labels, headings, placeholders, button text, error messages, tooltips, descriptions, status text, aria-label attributes, alt text, and title attributes, must come from a t() call. Hard-coded English strings in JSX are bugs. This applies to new components, new UI sections, and modifications that add visible text. The only exceptions are punctuation-only strings, single-character symbols, developer-facing content (console.log, comments, test assertions), and approximate skeleton-loader placeholder text standing in for a dynamic runtime value. Skeleton text that mirrors static t() content must still use t() (see the skeleton-loaders skill).

  1. Add the translation key to the appropriate namespace file in app/languages/en/ (and any other locale folders present, copying the English string verbatim as a placeholder)
  2. Use t('key') in the component, never a string literal
  3. One useTranslation() per component: never multiple calls for different namespaces
  4. Use {ns: 'other'} as second arg to t() for cross-namespace access
  5. Choose the most-used namespace for useTranslation() to minimize overrides
  6. Before adding a new key: search app/languages/en/ for existing equivalent strings
  7. Dynamic keys: ensure interpolated values have literal union types, not string

See references/translation-patterns.md for edge cases (keyPrefix, Trans component, dedup).

Gate 4: React 19 Idiom Check

GAIA writes React 19 idioms. The work here is to not regress to pre-19 habits, and to not pull in React's framework-level form APIs that React Router already owns.

Before writing forwardRef: don't. In React 19, ref is an ordinary prop on function components, so forwardRef is no longer needed (slated for deprecation in a future release). GAIA has zero forwardRef; every Form control destructures ref from props. Match that.

// BAD, needless indirection
const InputText = forwardRef<HTMLInputElement, Props>((props, ref) => <input ref={ref} {...props} />);
// GOOD, ref is just a prop
const InputText: FC<Props> = ({ref, ...rest}) => <input ref={ref} {...rest} />;

The ref type (Ref<T>, or ComponentProps<'input'> already carrying ref) is the typescript skill's domain.

Before writing && in JSX, make the left operand a real boolean. && returns its left operand when falsy. false/null/undefined render nothing, but a numeric 0 is a renderable value and leaks the literal "0" into the DOM. This is the most common React rendering bug, so coercing a numeric operand is mandatory, not a stylistic option. Lint catches the .length && <JSX/> form in real time (via no-restricted-syntax); the general count && <X/> case is caught at pre-merge audit by react-doctor's type-aware rendering-conditional-render rule, which reports any numeric operand as a Bug. Coerce as you write rather than waiting for the audit: count > 0, count !== 0, or !!count.

// BAD, renders "0" when the list is empty
{items.length && <List items={items} />}
// GOOD, force a real boolean
{items.length > 0 && <List items={items} />}

For render-or-nothing, a boolean-guarded && is the idiom; it replaces the old cond ? <X/> : null. A ternary is only for a genuine either/or where both arms render, never : null.

Before writing useContext or <Context.Provider>, use the React 19 forms. Read context with use() (unlike useContext, it may be called conditionally or after an early return); render the context object directly as the provider.

const nonce = use(NonceContext); // not useContext(NonceContext)
<NonceContext value={nonce}>{children}</NonceContext>; // not <NonceContext.Provider>

<Context.Provider>/<Context.Consumer> are legacy (deprecation planned). GAIA uses the <Context> shorthand and use() exclusively, never .Provider, .Consumer, or useContext. Convert any you find.

Stay in React Router's lane; don't reach for React's form Actions. GAIA submits through React Router <Form> / useFetcher + route action exports, validates with Conform + Zod (Gate 2), and reads pending/optimistic state from React Router. React 19's framework-level form hooks duplicate and fight that surface. When tempted, redirect:

React 19 API (don't use here) Use instead
useActionState, <form action={fn}> route action + useActionData
useFormStatus useNavigation().state / fetcher.state
useOptimistic fetcher-based optimism (useOptimisticThemeMode in useTheme.ts)
use(promise) for route data loader + useLoaderData (use(promise) only for non-route promises inside <Suspense>)

Metadata is the mirror case: GAIA renders <title>/<meta> as JSX (React 19 hoisting), not a React Router route meta/links export. Keep it that way; adding a route meta export to a page that already renders <title> in JSX produces duplicate tags.

When you do reach for React Router's API, read it from the version-matched docs shipped at node_modules/react-router/docs, not the web.

Rendering nothing from a return is enforced by @gaia-react/lint's no-null-render rule (autofix); a : null ternary arm is caught by no-restricted-syntax (report-only). No manual rewrite needed.

For useEffectEvent (the sanctioned replacement for stale-deps / latest-ref hacks) and ref-callback cleanup functions, see references/hook-patterns.md.

Component Structure

  • FC typing: const MyComponent: FC<Props> = ({...}) => ...
  • One component per file: keeps co-location clean and makes code-splitting predictable
  • Named React imports: import {useState} from 'react', never React.useState(), avoids the React namespace and makes tree-shaking explicit
  • Type imports: import type {ChangeEventHandler} from 'react', never React.FC
  • Event handler types: Prefer ChangeEventHandler<HTMLInputElement> over inline event typing
  • Event handler naming: handle{Action}{Element}, the {Element} is required so the name says what it does, not just when it fires; e.g. handleClickSave, handleChangeInput, handleCopyStack. A bare event name (handleClick, handleChange, handleSubmit) trips react-doctor/no-generic-handler-names.

Component Extraction

Extract when a section meets all criteria:

  1. Self-contained (own state/fetcher, or pure display with no shared state)
  2. Clear boundary (visible UI section with small props interface)
  3. ~60+ lines of JSX/logic

Do not extract when state/refs are shared across sections, extraction needs 5+ props/callbacks, section is under ~60 lines, or form validation is tightly coupled.

How: Create ParentComponent/NewSection/index.tsx, move exclusive types/state/handlers/JSX, define minimal Props type.

Route-Page Architecture

Route files (app/routes/)

Thin shell only:

  • loader / action functions
  • Zod schemas for the action
  • One-line default export: const MyRoute: FC = () => <MyPage />;

No UI code, hooks, state, or sub-components in route files. Metadata renders as JSX (<title>/<meta>) in the page, not a route meta export (Gate 4).

Page components (app/pages/)

app/pages/{Group}/{PascalName}Page/index.tsx                    # most pages
app/pages/{Group}/{Section}/{PascalName}Page/index.tsx          # only when a section grouping is needed

For loader data: use useLoaderData<typeof loader>() (import the loader type from the route file) or useLoaderData<LoaderData>() (import LoaderData from a sibling types.ts). Never define the type inline in the page component file itself.

Sub-components go in sibling folders. Tests/stories in {PageName}/tests/.

When stories need different loader data, put stubs.reactRouter() decorators on individual stories (not meta) to avoid nested Router errors with composeStory.

References

  • references/hook-patterns.md, Read when writing any Effect or useCallback, or when debugging stale closures, double-firing effects, or infinite re-renders.
  • references/conform-forms.md, full Conform + Zod form wiring walkthrough
  • references/translation-patterns.md, i18n edge cases, Trans component, dedup rules
Files (gaia)
  • references
    • conform-forms.md 3 KB
      # Conform + Zod Form Wiring
      
      Conform's Zod helpers import from the `@conform-to/zod/v4` subpath, the bare `@conform-to/zod` targets Zod 3 and throws at runtime (typecheck/lint/build don't catch it). The react-code SKILL.md carries this as the always-on rule; the examples below use `/v4` throughout.
      
      ## Basic Form Setup
      
      ### 1. Define Zod schema (in route file)
      
      ```tsx
      const schema = z.object({
        name: z.string().min(1),
        email: z.string().email(),
        role: z.literal(['admin', 'member']),
      });
      ```
      
      ### 2. Action (in route file)
      
      ```tsx
      export const action = async ({request}: ActionFunctionArgs) => {
        const formData = await request.formData();
        const submission = parseWithZod(formData, {schema});
      
        if (submission.status !== 'success') {
          return data({result: submission.reply()});
        }
      
        // Use submission.value for typed data
        await fetch('/api/users', {
          method: 'POST',
          headers: {'Content-Type': 'application/json'},
          body: JSON.stringify(submission.value),
        });
        return redirect('/users');
      };
      ```
      
      ### 3. Page component with useForm
      
      ```tsx
      import {useForm, getFormProps, getInputProps} from '@conform-to/react';
      import {getZodConstraint, parseWithZod} from '@conform-to/zod/v4';
      import {useTranslation} from 'react-i18next';
      
      const MyPage: FC = () => {
        const {t} = useTranslation('pages');
        const actionData = useActionData<{result: SubmissionResult}>();
      
        const [form, fields] = useForm({
          lastResult: actionData?.result,
          constraint: getZodConstraint(schema),
          onValidate: ({formData}) => parseWithZod(formData, {schema}),
          shouldValidate: 'onBlur',
          shouldRevalidate: 'onInput',
        });
      
        return (
          <Form method="post" {...getFormProps(form)}>
            <InputText
              {...getInputProps(fields.name, {type: 'text'})}
              label={t('nameLabel')}
              errors={fields.name.errors}
            />
            <InputText
              {...getInputProps(fields.email, {type: 'email'})}
              label={t('emailLabel')}
              errors={fields.email.errors}
            />
            <Select
              {...getInputProps(fields.role, {type: 'text'})}
              label={t('roleLabel')}
              options={roleOptions}
              errors={fields.role.errors}
            />
            <button type="submit">Save</button>
          </Form>
        );
      };
      ```
      
      ## Form Component Mapping
      
      For the native-element → Form-component table, see **Gate 2: Form Element Check** in `react-code/SKILL.md` (the authoritative superset, including the native-OK exceptions `hidden` / `file` / `range`).
      
      ## Compound Component Gotcha
      
      Conform reads stale hidden input values from compound components (YearMonthDay, TimePicker). Fix: native `addEventListener('input', e => e.stopPropagation())` via ref callback on container div + sync hidden input DOM value via `useRef` in `onChange`. React `onInput` won't work (SSR hydration puts both handlers on same node).
      
      ## Zod Patterns
      
      This project uses Zod 4, the typescript skill's `references/zod.md` is the full Zod 3 → Zod 4 migration map. Project convention: `z.literal([...])` not `z.enum()` for string unions (sort values alphanumerically).
      
    • hook-patterns.md 8.6 KB
      # Hook Patterns, Extended Examples
      
      ## Contents
      
      - [useEffect Anti-Patterns](#useeffect-anti-patterns)
      - [When Effects ARE Correct](#when-effects-are-correct)
      - [Strict Mode & Cleanup](#strict-mode--cleanup)
      - [useCallback, When to Use](#usecallback--when-to-use)
      - [useMemo, When to Use](#usememo--when-to-use)
      - [useEffectEvent, Non-Reactive Effect Logic](#useeffectevent-non-reactive-effect-logic)
      - [Ref Callback Cleanup](#ref-callback-cleanup)
      
      ---
      
      ## useEffect Anti-Patterns
      
      ### Don't transform data for rendering
      
      ```tsx
      // BAD, unnecessary state + Effect + extra render cycle
      const [filtered, setFiltered] = useState<Exercise[]>([]);
      useEffect(() => {
        setFiltered(exercises.filter((e) => e.muscleGroup === selected));
      }, [exercises, selected]);
      
      // GOOD, derive inline
      const filtered = exercises.filter((e) => e.muscleGroup === selected);
      ```
      
      ### Don't use Effects for expensive calculations
      
      ```tsx
      // BAD, triggers a render to set state, then Effect runs and triggers a second render
      useEffect(() => {
        setSorted(exercises.slice().sort((a, b) => a.name.localeCompare(b.name)));
      }, [exercises]);
      
      // GOOD, useMemo runs synchronously, no extra render
      const sorted = useMemo(
        () => exercises.slice().sort((a, b) => a.name.localeCompare(b.name)),
        [exercises]
      );
      ```
      
      ### Don't derive redundant state
      
      ```tsx
      // BAD, Effect sets state after render, causing an extra render cycle every time deps change
      useEffect(() => {
        setFullName(`${firstName} ${lastName}`);
      }, [firstName, lastName]);
      
      // GOOD
      const fullName = `${firstName} ${lastName}`;
      ```
      
      ### Don't put user-event logic in Effects
      
      ```tsx
      // BAD, notification in Effect triggered by state change; effects fire after render,
      // so the causal link between action and side effect is indirect; also runs on mount
      // and every dep change, not just the user action
      useEffect(() => {
        if (justAdded) showToast(`${product.name} added`);
      }, [justAdded]);
      
      // GOOD, in the event handler
      function handleAddToPlan() {
        dispatch({type: 'add', product});
        showToast(`${product.name} added to your plan`);
      }
      ```
      
      ### Don't chain Effects
      
      ```tsx
      // BAD, multiple Effects cascading state updates; each setState triggers its own render,
      // so n chained effects = n+1 render cycles
      useEffect(() => {
        setCard(deck[index]);
      }, [index]);
      useEffect(() => {
        setGoldCount(card.isGold ? count + 1 : count);
      }, [card]);
      
      // GOOD, derive everything from the event
      function pickCard(index: number) {
        const card = deck[index];
        const newGoldCount = card.isGold ? goldCardCount + 1 : goldCardCount;
        setIndex(index);
        setCard(card);
        setGoldCardCount(newGoldCount);
        setIsWon(newGoldCount >= 5);
      }
      ```
      
      ### Don't notify parent via Effect
      
      ```tsx
      // BAD, fires after every render where isOn changed, including the initial mount;
      // easy source of infinite loops if parent updates props that feed back into this child
      useEffect(() => {
        onChange(isOn);
      }, [isOn]);
      
      // GOOD
      function handleToggle() {
        const next = !isOn;
        setIsOn(next);
        onChange(next);
      }
      ```
      
      ### State reset, use key, not Effect
      
      ```tsx
      // BAD, Effect fires after the stale state has already rendered, causing a visible flash before reset
      useEffect(() => {
        setNotes('');
        setEditing(false);
      }, [userId]);
      
      // GOOD, key forces unmount/remount, all state resets before the first paint
      <WorkoutNotes key={userId} userId={userId} />;
      ```
      
      ---
      
      ## When Effects ARE Correct
      
      Effects are appropriate for synchronizing with external systems.
      
      ### Data fetching with ignore flag
      
      ```tsx
      useEffect(() => {
        let ignore = false;
      
        async function fetchExercises() {
          const {data} = await supabase
            .from('exercises')
            .select('*')
            .eq('gym_id', gymId);
          if (!ignore) setExercises(data ?? []);
        }
      
        fetchExercises();
        return () => {
          ignore = true;
        };
      }, [gymId]);
      ```
      
      ### External store subscription
      
      Prefer `useSyncExternalStore` when possible. Use Effect for third-party widgets or browser APIs that don't expose a subscribe/getSnapshot pattern.
      
      ---
      
      ## Strict Mode & Cleanup
      
      React 18 Strict Mode mounts → unmounts → remounts every component in development. Effects run twice. Cleanup must fully undo the setup, or the second invocation leaves duplicate state or stale listeners. This is intentional, it surfaces missing cleanups before they leak in production.
      
      ```tsx
      // BAD, missing cleanup leaks the listener (and fires twice in dev with Strict Mode)
      useEffect(() => {
        window.addEventListener('resize', handleResize);
      }, [handleResize]);
      
      // GOOD, cleanup mirrors setup exactly
      useEffect(() => {
        window.addEventListener('resize', handleResize);
        return () => window.removeEventListener('resize', handleResize);
      }, [handleResize]);
      ```
      
      The same principle applies to any subscription, timer, or third-party widget: if the Effect sets something up, the cleanup must tear it down completely.
      
      ---
      
      ## useCallback, When to Use
      
      ```tsx
      // ✅ Passed to memo-wrapped child, prevents unnecessary child re-renders
      const handleSubmitForm = useCallback((data: FormData) => {
        post('/api/submit', data);
      }, []);
      return <MemoizedForm onSubmit={handleSubmitForm} />;
      
      // ✅ Used in useEffect dependency array, keeps a stable reference
      const fetchData = useCallback(async () => {
        const result = await api.get(endpoint);
        setData(result);
      }, [endpoint]);
      
      useEffect(() => {
        fetchData();
      }, [fetchData]);
      
      // ❌ Not passed to a memo child, not in any hook deps, skip useCallback
      const handleClickIncrement = () => {
        setCount(count + 1);
      };
      ```
      
      ### Anti-pattern: wrapping every handler "just in case"
      
      ```tsx
      // BAD, premature optimization; every render still allocates the deps array,
      // so if deps change often useCallback saves nothing. An empty deps array is
      // a stale closure waiting to happen if the handler ever needs to read state or props.
      const handleChangeName = useCallback((e: ChangeEvent<HTMLInputElement>) => {
        setName(e.target.value);
      }, []); // looks safe now, breaks the moment handleChangeName needs to read other state
      
      // GOOD, plain function is the right default
      const handleChangeName = (e: ChangeEvent<HTMLInputElement>) => {
        setName(e.target.value);
      };
      ```
      
      The default should be a plain function. Reach for `useCallback` only when you have a concrete reason: a `memo`-wrapped child that's visibly re-rendering, or a stable reference needed by an Effect.
      
      ---
      
      ## useMemo, When to Use
      
      Use `useMemo` for computations that are:
      
      - Genuinely expensive (sorting/filtering large arrays, building derived structures)
      - Passed as props to `memo`-wrapped children where reference stability matters
      - Used in `useEffect` dependency arrays to maintain a stable reference
      
      ### Anti-pattern: memoizing cheap calculations
      
      ```tsx
      // BAD, trivial calculation; memo bookkeeping costs more than it saves
      const label = useMemo(() => `Hello, ${name}`, [name]);
      
      // GOOD, just compute inline
      const label = `Hello, ${name}`;
      ```
      
      Missing or stale deps in `useMemo` introduce the same stale closure bugs as `useCallback`, the memoized value silently reads an old snapshot of whatever was omitted from the deps array.
      
      ---
      
      ## useEffectEvent, Non-Reactive Effect Logic
      
      `useEffectEvent` (stable in React 19.2) extracts a non-reactive read out of an Effect, so the Effect uses a current value without listing it as a dependency. It is the sanctioned replacement for "I had to omit X from the deps array" and for the latest-ref workaround (e.g. `TextArea` keeping `onAutoSizeRef.current = onAutoSize` behind an `eslint-disable react-hooks/refs`).
      
      ```tsx
      // onVisit reads numItems, but the Effect should re-run only when url changes
      const onVisit = useEffectEvent((visitedUrl: string) => {
        log(visitedUrl, numItems); // numItems is non-reactive here
      });
      
      useEffect(() => {
        onVisit(url);
      }, [url]); // numItems intentionally absent, and lint won't demand it
      ```
      
      Use sparingly, only for values that are genuinely non-reactive (the Effect should not re-run when they change). If the Effect should react to the value, keep it in the deps array.
      
      ---
      
      ## Ref Callback Cleanup
      
      A ref callback may return a cleanup function, run when the element leaves the DOM. When it returns a cleanup, React no longer calls the callback again with `null`.
      
      ```tsx
      <div
        ref={(node) => {
          const observer = new ResizeObserver(() => {/* … */});
          observer.observe(node);
          return () => observer.disconnect(); // runs on unmount
        }}
      />;
      ```
      
      **Strict-TS pitfall:** because a returned value is now read as a cleanup function, an arrow ref-callback with an implicit return of a non-`undefined` value flags. Use a block body so the callback returns `undefined`.
      
      ```tsx
      // BAD, implicit return of the assignment is read as a cleanup
      <input ref={(node) => (ref.current = node)} />;
      // GOOD, block body returns undefined
      <input ref={(node) => { ref.current = node; }} />;
      ```
      
    • translation-patterns.md 4.2 KB
      # Translation Patterns, Edge Cases
      
      ## Core Rule
      
      One `useTranslation()` per component. Use `{ns: 'other'}` for cross-namespace access.
      
      ```tsx
      // GOOD
      const {t} = useTranslation('pages');
      t('onboarding.step1.title'); // 'pages' namespace
      t('previous', {ns: 'common'}); // override to 'common'
      
      // BAD
      const {t} = useTranslation('pages');
      const {t: tc} = useTranslation('common');
      ```
      
      Choose whichever namespace is used most frequently. If more calls override than use the declared namespace, switch it.
      
      ## keyPrefix
      
      `keyPrefix` is useful when many keys share a deep prefix, it keeps `t()` calls short. But it conflicts with `{ns: '...'}` overrides (the prefix is applied before the namespace switch, producing wrong keys). If you need both, drop `keyPrefix` and prefix manually:
      
      ```tsx
      // GOOD, keyPrefix alone, no namespace overrides needed
      const {t} = useTranslation('pages', {keyPrefix: 'onboarding.step1'});
      t('title'); // → pages:onboarding.step1.title
      t('subtitle'); // → pages:onboarding.step1.subtitle
      
      // BAD, keyPrefix + namespace override: prefix is misapplied
      const {t} = useTranslation('pages', {keyPrefix: 'onboarding.step1'});
      t('previous', {ns: 'common'}); // ❌ looks up common:onboarding.step1.previous
      
      // GOOD, drop keyPrefix when namespace overrides needed
      const {t} = useTranslation('pages');
      t('onboarding.step1.title');
      t('previous', {ns: 'common'}); // ✓
      ```
      
      ## Dynamic Translation Keys
      
      i18next's typed `t()` only accepts statically-known keys. Template literals with `${string}` fail type-checking.
      
      **Fix:** Ensure the interpolated value has a literal union type, not `string`.
      
      ```tsx
      // BAD, value is string
      const options = values.map((value) => ({
        label: t(`exercises.categoryValues.${value}`), // TS error
        value,
      }));
      
      // BAD, casting is a workaround
      label: t(`exercises.categoryValues.${value}` as 'exercises.categoryValues.cardio'),
      
      // GOOD, value is ExerciseCategory (literal union), typed at prop/LoaderData level
      import type {ExerciseCategory} from '~/types/database';
      // categoryOptions: ExerciseCategory[]  ← typed upstream, no cast needed
      const options = categoryOptions.map((value) => ({
        label: t(`exercises.categoryValues.${value}`), // ✓ TypeScript happy
        value,
      }));
      ```
      
      **Where to define the union type:** add it to `app/types/database.ts` alongside other DB-derived types. Then use it in `LoaderData`, component props, and anywhere else the value flows, the template literal in `t()` will just work.
      
      ## Inline Styled Segments (Trans component)
      
      When part of a translated string needs different styling, use `Trans` with XML tags, never split into separate keys:
      
      ```ts
      // Translation string
      previousWorkout: 'Previous <accent>Workout</accent>',
      ```
      
      ```tsx
      // Component
      import {Trans} from 'react-i18next';
      
      // Pass ns as a separate prop, never embed it in i18nKey.
      // i18nKey is namespace-relative: "dashboard.previousWorkout", not "pages:dashboard.previousWorkout"
      <Trans
        components={{accent: <span className="text-orange-500" />}}
        i18nKey="dashboard.previousWorkout"
        ns="pages"
      />;
      ```
      
      ## String Deduplication
      
      Before adding a new key:
      
      1. Search `app/languages/en/common.ts` for generic labels
      2. Search all `app/languages/en/` files for the exact string value
      3. If found, reuse with namespace override: `t('key', {ns: 'namespace'})`
      4. If not found, add to the most appropriate namespace
      
      ### Where shared labels belong
      
      - **Enum display labels** → `common` namespace, snake_case keys matching DB values (enables ``t(`key.${dbValue}`, {ns: 'common'})``)
      - **Generic UI actions** (Save, Cancel, Edit, etc.) → already in `common`
      - **Page-specific content** → page's namespace
      
      **Locale placeholders:** when adding a new locale folder, copy the English string verbatim, no empty strings, no TODO comments. Translation happens in a separate pass.
      
      ## Plurals
      
      i18next uses `_one`/`_other` key suffixes for pluralization:
      
      ```ts
      // Translation file (en)
      exerciseCount_one: '{{count}} exercise',
      exerciseCount_other: '{{count}} exercises',
      ```
      
      ```tsx
      // Component, i18next selects the right suffix automatically
      t('exerciseCount', {count: n});
      ```
      
      Always define both `_one` and `_other`. For locales without grammatical plural (e.g. Japanese, Chinese), define only `_other`.
      
  • SKILL.md 14.7 KB
    ---
    name: react-code
    description: Patterns and conventions for writing and editing React code, including components and hooks. Use this skill whenever writing or reviewing React components, hooks (useEffect, useCallback, useState), event handlers, or component extraction decisions. Also trigger when debugging stale closures, infinite re-renders, or unnecessary re-renders caused by memoization issues, or when deciding whether to add a dependency, reach for a web-platform API (Intl, URL, crypto.randomUUID), or hand-roll a primitive. Also trigger when choosing a React 19 idiom, deciding between forwardRef and ref-as-prop, useContext and use(), or Context.Provider and the Context shorthand; when conditional rendering risks the && numeric-0 leak; or when tempted to reach for React's form Actions (useActionState, useFormStatus, useOptimistic) instead of React Router's form handling.
    ---
    
    # React Code
    
    Write and edit React components, pages, routes, hooks, and forms following project conventions.
    
    ## Reach for the Platform First
    
    Before installing a package or hand-rolling a primitive, walk this ladder and stop at the first hit:
    
    1. **Existing GAIA code**, a component, hook, or util already covers it (form inputs → Gate 2).
    2. **Web platform**, a browser API or native element does the job: `Intl` (dates, numbers, lists, plurals), `URL` / `URLSearchParams`, `crypto.randomUUID()`, `structuredClone()`, `AbortController`, native `Array` / `Object` methods, `<dialog>`, modern CSS (`:has()`, container queries).
    3. **Already-installed dependency**, check `package.json` before adding a sibling that does the same job. For component/hook traps Claude often hand-rolls (client-only/useHydrated, sse, debounce-fetcher), see the remix-utils decision map at `wiki/dependencies/remix-utils.md` before reinventing.
    4. **New dependency**, only when 1-3 genuinely fall short; the added weight has to earn its place.
    5. **Custom code**, last resort, kept minimal.
    
    The largest real savings come from `Intl` over date/number-formatting libraries and native collection methods over `lodash`/`underscore` (already enforced by `you-dont-need-lodash-underscore`). Reaching for the platform replaces a needless dependency or bespoke widget; it never overrides accessibility, input validation, or an existing GAIA component (a wrapper exists for a reason).
    
    ## Pre-Flight Gates
    
    Most hook bugs come from misidentifying the type of problem being solved. Before writing or editing hooks, run through these gate, it only applies when the relevant pattern is present in your changes.
    
    ### Gate 1: Hook Check
    
    **Before writing `useEffect`:**
    
    1. Can I calculate this during render? → Derive inline or `useMemo`, no Effect needed.
    2. Does this respond to a user action? → Put it in the event handler, no Effect needed.
    3. Am I syncing state to other state? → Derive it; remove the redundant state, no Effect needed.
    4. Am I notifying a parent of a state change? → Call both setters in the handler, no Effect needed.
    5. Do I need to reset child state when a prop changes? → Use `key`, no Effect needed.
    6. Am I synchronizing with an external system (browser API, third-party widget, network)? → Effect is appropriate here. Add cleanup. For data fetching, include an `ignore` flag.
    
    **Before writing `useCallback`:**
    
    Only use when the function is:
    
    1. Passed as a prop to a `memo`-wrapped component
    2. A dependency of `useEffect`, `useMemo`, or another `useCallback`
    3. Passed to a child that uses it in a hook dependency array
    
    If none apply, skip `useCallback`, it adds indirection without benefit.
    
    **`useState` type inference:** Omit explicit type when inferable from the default value. Add types for unions or complex objects. For an absent initial value, prefer `undefined` over `null` (GAIA never-null): `useState<T>()` is already typed `T | undefined`.
    
    ### Gate 2: Form Element Check
    
    **Before writing `<input>`, `<select>`, `<textarea>`, or `<input type="checkbox">`:**
    
    | Native element                         | Use instead                                                                     |
    | -------------------------------------- | ------------------------------------------------------------------------------- |
    | `<input type="text">`                  | `InputText` (`~/components/Form/InputText`)                                     |
    | `<input type="email">`                 | `InputEmail` (`~/components/Form/InputEmail`)                                   |
    | `<input type="password">`              | `InputPassword` (`~/components/Form/InputPassword`)                             |
    | `<input type="checkbox">` (single)     | `Checkbox` (`~/components/Form/Checkbox`)                                       |
    | `<input type="checkbox">` (group)      | `Checkboxes` (`~/components/Form/Checkboxes`), needs `options: Option[]`        |
    | `<input type="radio">` / radio group   | `RadioButtons` (`~/components/Form/RadioButtons`), needs `options: Option[]`    |
    | `<select>`                             | `Select` (`~/components/Form/Select`), needs `name` + `options: SelectOption[]` |
    | `<textarea>`                           | `TextArea` (`~/components/Form/TextArea`), needs `name`; auto-resizes           |
    | Date (year/month/day)                  | `YearMonthDay` (`~/components/Form/YearMonthDay`)                               |
    | Field with label + error + description | `Field` (`~/components/Form/Field`)                                             |
    
    **Exceptions (native OK):** `<input type="hidden">`, `<input type="file">`, `<input type="range">`.
    
    `Select` requires `options: SelectOption[]` (`{label, value}`). Build this array (with `useMemo` if derived from translations/data) rather than inline `<option>` elements.
    
    **CRITICAL, `@conform-to/zod`:** Always import from `/v4` subpath. The default export targets Zod v3 and causes a runtime error that typecheck/lint/build do NOT catch.
    
    ```tsx
    // BAD, runtime error
    import {parseWithZod} from '@conform-to/zod';
    // GOOD
    import {parseWithZod} from '@conform-to/zod/v4';
    ```
    
    See `references/conform-forms.md` for full Conform + Zod wiring. Beyond the import path, all Zod schemas use Zod 4 syntax, the typescript skill's `references/zod.md` is the canonical Zod 3 → Zod 4 migration map (`z.strictObject`, top-level string formats, etc.).
    
    ### Gate 3: Translation Check
    
    **Before writing ANY user-visible string in JSX:**
    
    Every string a user can see or hear, labels, headings, placeholders, button text, error messages, tooltips, descriptions, status text, `aria-label` attributes, `alt` text, and `title` attributes, must come from a `t()` call. Hard-coded English strings in JSX are bugs. This applies to new components, new UI sections, and modifications that add visible text. The only exceptions are punctuation-only strings, single-character symbols, developer-facing content (console.log, comments, test assertions), and approximate skeleton-loader placeholder text standing in for a dynamic runtime value. Skeleton text that mirrors static `t()` content must still use `t()` (see the skeleton-loaders skill).
    
    1. Add the translation key to the appropriate namespace file in `app/languages/en/` (and any other locale folders present, copying the English string verbatim as a placeholder)
    2. Use `t('key')` in the component, never a string literal
    3. **One `useTranslation()` per component**: never multiple calls for different namespaces
    4. Use `{ns: 'other'}` as second arg to `t()` for cross-namespace access
    5. Choose the most-used namespace for `useTranslation()` to minimize overrides
    6. **Before adding a new key:** search `app/languages/en/` for existing equivalent strings
    7. Dynamic keys: ensure interpolated values have literal union types, not `string`
    
    See `references/translation-patterns.md` for edge cases (keyPrefix, Trans component, dedup).
    
    ### Gate 4: React 19 Idiom Check
    
    GAIA writes React 19 idioms. The work here is to not regress to pre-19 habits, and to not pull in React's framework-level form APIs that React Router already owns.
    
    **Before writing `forwardRef`: don't.** In React 19, `ref` is an ordinary prop on function components, so `forwardRef` is no longer needed (slated for deprecation in a future release). GAIA has zero `forwardRef`; every Form control destructures `ref` from props. Match that.
    
    ```tsx
    // BAD, needless indirection
    const InputText = forwardRef<HTMLInputElement, Props>((props, ref) => <input ref={ref} {...props} />);
    // GOOD, ref is just a prop
    const InputText: FC<Props> = ({ref, ...rest}) => <input ref={ref} {...rest} />;
    ```
    
    The ref _type_ (`Ref<T>`, or `ComponentProps<'input'>` already carrying `ref`) is the typescript skill's domain.
    
    **Before writing `&&` in JSX, make the left operand a real boolean.** `&&` returns its left operand when falsy. `false`/`null`/`undefined` render nothing, but a numeric **`0`** is a renderable value and leaks the literal "0" into the DOM. This is the most common React rendering bug, so coercing a numeric operand is mandatory, not a stylistic option. **Lint catches the `.length && <JSX/>` form in real time** (via `no-restricted-syntax`); the general `count && <X/>` case is caught at pre-merge audit by react-doctor's type-aware `rendering-conditional-render` rule, which reports any numeric operand as a Bug. Coerce as you write rather than waiting for the audit: `count > 0`, `count !== 0`, or `!!count`.
    
    ```tsx
    // BAD, renders "0" when the list is empty
    {items.length && <List items={items} />}
    // GOOD, force a real boolean
    {items.length > 0 && <List items={items} />}
    ```
    
    For render-or-nothing, a boolean-guarded `&&` is the idiom; it replaces the old `cond ? <X/> : null`. A ternary is only for a genuine either/or where both arms render, never `: null`.
    
    **Before writing `useContext` or `<Context.Provider>`, use the React 19 forms.** Read context with `use()` (unlike `useContext`, it may be called conditionally or after an early return); render the context object directly as the provider.
    
    ```tsx
    const nonce = use(NonceContext); // not useContext(NonceContext)
    <NonceContext value={nonce}>{children}</NonceContext>; // not <NonceContext.Provider>
    ```
    
    `<Context.Provider>`/`<Context.Consumer>` are legacy (deprecation planned). GAIA uses the `<Context>` shorthand and `use()` exclusively, never `.Provider`, `.Consumer`, or `useContext`. Convert any you find.
    
    **Stay in React Router's lane; don't reach for React's form Actions.** GAIA submits through React Router `<Form>` / `useFetcher` + route `action` exports, validates with Conform + Zod (Gate 2), and reads pending/optimistic state from React Router. React 19's framework-level form hooks duplicate and fight that surface. When tempted, redirect:
    
    | React 19 API (don't use here)          | Use instead                                                                                   |
    | -------------------------------------- | --------------------------------------------------------------------------------------------- |
    | `useActionState`, `<form action={fn}>` | route `action` + `useActionData`                                                              |
    | `useFormStatus`                        | `useNavigation().state` / `fetcher.state`                                                     |
    | `useOptimistic`                        | fetcher-based optimism (`useOptimisticThemeMode` in `useTheme.ts`)                            |
    | `use(promise)` for route data          | loader + `useLoaderData` (`use(promise)` only for non-route promises inside `<Suspense>`)     |
    
    Metadata is the mirror case: GAIA renders `<title>`/`<meta>` as JSX (React 19 hoisting), not a React Router route `meta`/`links` export. Keep it that way; adding a route `meta` export to a page that already renders `<title>` in JSX produces duplicate tags.
    
    When you do reach for React Router's API, read it from the version-matched docs shipped at `node_modules/react-router/docs`, not the web.
    
    Rendering nothing from a `return` is enforced by `@gaia-react/lint`'s `no-null-render` rule (autofix); a `: null` ternary arm is caught by `no-restricted-syntax` (report-only). No manual rewrite needed.
    
    For `useEffectEvent` (the sanctioned replacement for stale-deps / latest-ref hacks) and ref-callback cleanup functions, see `references/hook-patterns.md`.
    
    ## Component Structure
    
    - **FC typing:** `const MyComponent: FC<Props> = ({...}) => ...`
    - **One component per file**: keeps co-location clean and makes code-splitting predictable
    - **Named React imports:** `import {useState} from 'react'`, never `React.useState()`, avoids the React namespace and makes tree-shaking explicit
    - **Type imports:** `import type {ChangeEventHandler} from 'react'`, never `React.FC`
    - **Event handler types:** Prefer `ChangeEventHandler<HTMLInputElement>` over inline event typing
    - **Event handler naming:** `handle{Action}{Element}`, the `{Element}` is required so the name says _what it does_, not just _when it fires_; e.g. `handleClickSave`, `handleChangeInput`, `handleCopyStack`. A bare event name (`handleClick`, `handleChange`, `handleSubmit`) trips `react-doctor/no-generic-handler-names`.
    
    ### Component Extraction
    
    Extract when a section meets **all** criteria:
    
    1. Self-contained (own state/fetcher, or pure display with no shared state)
    2. Clear boundary (visible UI section with small props interface)
    3. ~60+ lines of JSX/logic
    
    **Do not extract** when state/refs are shared across sections, extraction needs 5+ props/callbacks, section is under ~60 lines, or form validation is tightly coupled.
    
    How: Create `ParentComponent/NewSection/index.tsx`, move exclusive types/state/handlers/JSX, define minimal `Props` type.
    
    ## Route-Page Architecture
    
    ### Route files (`app/routes/`)
    
    Thin shell only:
    
    - `loader` / `action` functions
    - Zod schemas for the action
    - One-line default export: `const MyRoute: FC = () => <MyPage />;`
    
    **No UI code, hooks, state, or sub-components in route files.** Metadata renders as JSX (`<title>`/`<meta>`) in the page, not a route `meta` export (Gate 4).
    
    ### Page components (`app/pages/`)
    
    ```
    app/pages/{Group}/{PascalName}Page/index.tsx                    # most pages
    app/pages/{Group}/{Section}/{PascalName}Page/index.tsx          # only when a section grouping is needed
    ```
    
    For loader data: use `useLoaderData<typeof loader>()` (import the `loader` type from the route file) or `useLoaderData<LoaderData>()` (import `LoaderData` from a sibling `types.ts`). Never define the type inline in the page component file itself.
    
    Sub-components go in sibling folders. Tests/stories in `{PageName}/tests/`.
    
    When stories need different loader data, put `stubs.reactRouter()` decorators on individual stories (not meta) to avoid nested Router errors with `composeStory`.
    
    ## References
    
    - `references/hook-patterns.md`, Read when writing any Effect or useCallback, or when debugging stale closures, double-firing effects, or infinite re-renders.
    - `references/conform-forms.md`, full Conform + Zod form wiring walkthrough
    - `references/translation-patterns.md`, i18n edge cases, Trans component, dedup rules
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related