agentleFS
kennguyen887kennguyen887/agent-foundation/skills/write-frontend-code/SKILL.md

write-frontend-code

Skill1 starsChanged 5 months agoMITView on GitHub

Use when writing FE code inside a feature — data fetching & mutations (React Query), API + DTOs, state (Zustand + Context), custom hooks, forms (RHF + Yup), heavily-used packages, performance, styling (Tailwind + tokens + CSS Modules), config, i18n, SSR/hydration. React/Next.js reference, framework-flexible.

  • Reads credentials

The file

We split this skill into the rules it gives an agent

1 rule. About 0 are common, 0 are distinctive, and 1 is very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    No scattered process.env (all via the Config module).

    line 238

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Write frontend code

Examples: React + Next.js (Pages Router), neutral listing domain. principle → ▸ Example → ▸ Other stacks. Where files go: structure-a-frontend-app. General style (early return, pipelines, naming, SOLID, deep copy, casts): code-conventions.

The data/state stack ships through the shared UI lib. React Query, Zustand, debounce, the HTTP client, theme tokens etc. are re-exported from @org/web-ui (placeholder for your in-house design-system package) — so they aren't direct deps of the app. Import from @org/web-ui[/utils| /store|/queries], not the raw libraries.

1. Data fetching & mutations — React Query

Server data goes through a query layer per feature, never ad-hoc fetch in components. A feature's data folder has four files: .keys.ts (key factory), .queries.ts (reads), .mutations.ts (writes), .types.ts.

  • Key factory — namespaced nested arrays, so invalidation is precise:
    // listing.keys.ts
    const listingKeys = { all: ['listing'] as const };
    export const listingQuery = {
      detail: (id?: string) => (id ? [...listingKeys.all, 'detail', id] : [...listingKeys.all, 'detail']),
      list:   (params?: ListListingsRequest) => [...listingKeys.all, 'list', params] as const,
    };
  • Query hook — enabled guard + per-query staleTime override + placeholderData to avoid flicker:
    export const useListingDetail = ({ id, options = {} }) =>
      useQuery(listingQuery.detail(id), () => listingService.getDetail(id!), {
        enabled: !!id, staleTime: 30_000, placeholderData: keepPreviousData, ...options,
      });
  • Mutation hook — invalidate the affected keys in onSuccess:
    // listing.mutations.ts
    export const usePublishListing = () => {
      const qc = useQueryClient();
      return useMutation(({ id }) => listingService.publish(id), {
        onSuccess: () => qc.invalidateQueries({ queryKey: listingQuery.detail() }),
      });
    };
  • Global client config (once): staleTime 10m, gcTime 30m, refetchOnWindowFocus false, retry false, refetchInterval false. Override per-query (staleTime: 0) only for live data. ▸ Other stacks: SWR / TanStack Query; key-factory + hook-per-query + invalidate-on-mutate carries over.

2. API layer — one base client + service classes + DTOs

  • One HTTP base client (BaseHttp, an Axios instance) owns interceptors: attach bearer token; queue requests during a 401 refresh (concurrent 401s → one refresh, then replay); normalize errors (handleHttpError re-throws a consistent Error with .response); serialize query params once (qs.stringify(..., { arrayFormat: 'brackets' })).
  • One service class per entity extends the base; components/queries call the service, never axios:
    export class ListingService extends BaseHttp {
      getList(p: ListListingsRequest) { return this.get('listings', { params: p }).then((r) => r.data); }
      publish(id: string) { return this.post(`listings/${id}/publish`).then((r) => r.data); }
    }
  • DTOs hand-authored, split models/request/ vs models/response/. Build payloads fluently: const body = Builder(CreateListingBody).title(form.title).price(form.price).build(); ▸ Other stacks: a typed client / generated SDK — one wrapper owns auth + error handling.

3. State — server vs client vs cross-cutting (don't mix)

KindToolUse for
Server dataReact Query (§1)anything fetched; cache/refetch/invalidate
Local UI stateZustand store, one per featurefilters, pagination, selection, wizard step, flags
Cross-cuttingReact Context, set once on bootauth/session, org/profile, alerts, locale
  • Zustand store shape — State + Actions as separate interfaces, a resetState, immutable merges via get():
    const create: StateCreator<ListingListStore> = (set, get) => ({
      ...initialState,
      setFilter: (f) => set({ filter: { ...get().filter, ...f } }),
      addSelected: (l) => set((s) => s.selected.some((x) => x.id === l.id) ? s : { selected: [...s.selected, l] }),
      resetState: () => set({ ...initialState }),
    });
    export const [useListingListStore] = createStore(create, { storeName: 'listing-list' });
  • Select narrowly + shallow to avoid full-store re-renders: const { filter, setFilter } = useListingListStore((s) => pick(s, ['filter','setFilter']), shallow);
  • Never copy server data into Zustand (goes stale). If a query must seed store state, do it in the queryFn wrapped in unstable_batchedUpdates(...) (one re-render, not N). ▸ Other stacks: any store lib + context; the server/client/cross-cutting split is the durable idea.

4. Custom hooks — feature .actions.ts pattern + reusable hooks

  • Feature logic lives in a hook, not the component. A feature action hook consolidates form submit
    • mutation + navigation + error + UI flags, with a synchronous re-entry guard so double-clicks can't double-submit:
    export const useSaveListing = () => {
      const { handleSubmit } = useFormContext<ListingForm>();
      const { setSaving } = useListingListStore();
      const { mutateAsync } = usePublishListing();
      const busy = useRef(false);
      const onSave = () => {
        if (busy.current) return;                                  // sync guard
        handleSubmit(async (form) => {
          busy.current = true; setSaving(true);
          try { await mutateAsync({ id: form.id }); router.push(Routes.Listings); }
          catch (e) { handleError(e); }
          finally { busy.current = false; setSaving(false); }
        })();
      };
      return { onSave };
    };
  • Reusable hooks (src/hooks/ general, src/helpers/hooks/ domain): useModalState() → { visible, show, hide, toggle }; useDebounced(value, ms) (value debounce w/ cleanup); usePagedSearchCombobox({ fetchRows, mapRows, pageSize }) (debounced search + load-more; refs for a fetch-lock + a generation counter to drop stale responses); useMyProfile() (reads a context → memoized role booleans). Always use*-named; memoize derived values.

5. Forms & validation — React Hook Form + Yup

  • RHF + Yup via yupResolver; schema in src/schemas/<feature>.schema.ts. Dynamic/conditional schemas with yup.when() / yup.lazy(). mode: 'onChange' for live feedback. Wrap a form in <FormProvider>, read it in nested fields via useFormContext().
  • Perf on big forms: subscribe to single fields with useWatch({ name }) instead of watch() (which re-renders on every keystroke). Wrap inputs in shared Field components that show formState.errors.

6. Packages we lean on

Public OSS named directly; in-house bits come via @org/web-ui.

PackageFor
react-hook-form + @hookform/resolvers + yupforms + schema validation
@org/web-ui (+ /utils /store /queries)design system; React-Query/Zustand wrappers; CommonUtil (debounce, pick, uniqBy), DateUtil
axiosHTTP (only inside BaseHttp)
classnamesconditional class strings (§7)
lodashutilities — import per-function (import pick from 'lodash/pick') for tree-shaking
decimal.jsmoney / precise math (never float)
builder-patternfluent request-DTO construction
qs / query-stringURL query (de)serialization
uuid · next-auth · socket.io-clientids · auth/session · realtime

▸ Other stacks: swap names, keep the roles (form lib + schema, one HTTP wrapper, a decimal lib, a class-name helper).

7. Styling — Tailwind + tokens + CSS Modules

  • Tailwind-first. Colors/spacing/radius/breakpoints come from the shared theme (@org/web-ui tokens extended in tailwind.config.js) — never hard-code hex; use token classes.
  • Conditional classes via classnames (alias cn), incl. arbitrary variants:
    <div className={cn('listing-row', { 'is-error': hasError }, isActive && '[&_svg]:text-primary')} />
  • CSS Modules (*.module.scss) for component-scoped styling beyond utilities; share SCSS vars via styles/variables.scss. styles/globals.css holds resets + third-party UI overrides + global overlays.
  • Integrating a component library? Disable Tailwind preflight to avoid reset clashes. Dark mode via the class strategy. ▸ Other stacks: utility CSS + scoped modules + one token source.

8. Performance

  • Memoize the expensive, not everything. React.memo on heavy components (editors, charts, media, big lists); useCallback for handlers passed to memoized children/list rows; useMemo for derived lists/objects. Don't memo trivial leaves.
  • Zustand shallow + narrow selectors (§3) — usually the biggest re-render win.
  • React Query tuning: lean on the 10-min staleTime; narrow with select; placeholderData/ keepPreviousData to avoid flicker on param change; staleTime: 0 only for truly live data.
  • Code-split heavy client-only modules with next/dynamic + { ssr: false } (rich-text editor, embedded reports, video, PDF) — keeps them out of the SSR bundle and initial load:
    const Editor = dynamic(() => import('src/components/Editor'), { ssr: false, loading: () => <Spin /> });
  • Images: next/image (lazy + blur placeholder) via a shared wrapper.
  • Debounce input-driven work (search ~300ms, expensive calc ~400ms, network ~1000ms) via the shared util.
  • Lists: pagination by default; add virtualization only when a page truly renders hundreds of rows.

9. Config — typed, public vs private

One typed config module (src/Config.ts): public (client-exposed; NEXT_PUBLIC_*) + private (server-only). Read config through the module, never scatter process.env; safe defaults / fail fast. ▸ Other stacks: a settings module + the framework's public-env prefix.

10. i18n

A localization context + locale JSON with dot-notation namespaces (common.title); a useLanguage(namespace) hook returns t(key). Locale from cookie, switchable. ▸ Other stacks: i18next/react-intl.

11. SSR / hydration gotchas (FE-critical)

  • Guard browser-only access: typeof window !== 'undefined' before window/document/ localStorage; use an isomorphic layout effect.
  • SSR-safe defaults, then hydrate (avoids hydration mismatch); ssr: false for components that can't render server-side.
  • Hard navigation (window.location) for unrecoverable/auth/maintenance states; router push for in-app nav.

12. Error handling & user feedback

A layered stack, not try/catch sprinkled everywhere:

  • Global HTTP interceptor (in BaseHttp, §2) maps transport errors centrally: 401 → queued refresh + retry; 403 → access message; 503/maintenance → redirect; otherwise → a normalized Error with .response for callers.
  • A class ErrorBoundary catches render-time errors and shows a fallback per route/region.
  • Alerts/toasts via context, not per-component state: an AlertContext keyed by group id (openAlert('listing-form', { type: 'error', content })) + a toast/notification util for transient messages. A shared handleError(e) turns a caught error into a user-facing dialog (and logs out on auth-invalidation).
  • Form errors surface from formState.errors via the shared Field components (§5). ▸ Other stacks: one global request-error handler + an error boundary + a toast/alert channel — don't re-handle the same statuses at every call site. (Auth/session flow: secure-a-frontend-app.)

13. Realtime (sockets)

  • One socket client (singleton) owns the lifecycle, not per-component connections:
    class SocketClient {
      private socket?: Socket;
      init(role: string) {
        this.socket = io(Config.SOCKET_URL, { auth: { token: BaseHttp.accessToken },
          query: { role }, transports: ['websocket'] });
        this.socket.on('connect', onConnect); this.socket.on('exception', handleError);
      }
      emit(a, p, cb?) { this.socket?.emit(a, p, cb); }
      on(a, cb) { this.socket?.on(a, cb); }  off(a, cb) { this.socket?.off(a, cb); }
      disconnect() { this.socket?.removeAllListeners(); this.socket?.disconnect(); }
    }
  • Init after auth (token available); remove listeners in the useEffect cleanup and disconnect on logout. Event names live in an enum; wrap a domain event stream in a context. ▸ Other stacks: a single connection manager + per-effect subscribe/unsubscribe; never leak listeners.

14. Providers & app-init

  • Flatten the _app provider nest with a combineProviders([A, B, C]) helper instead of deep JSX:
    const Providers = combineProviders([SessionProvider, QueryProvider, AuthProvider, AlertProvider, OrgProvider]);
  • Init app-wide side-effects once on mount in a useConfigs-style hook: logging + a push provider (e.g. OneSignal) — init() → tag the user (orgId/role from session/config) → clean up on unmount (logout). Never hard-code ids.

Verification

  • Server data: a queries/<feature> hook (key factory + useQuery); writes via .mutations.ts that invalidateQueries the right key; no raw fetch/axios in components.
  • Client state in a per-feature Zustand store, selected with shallow; no server data duplicated there.
  • Forms use RHF + a src/schemas/*.schema.ts; big forms use useWatch on specific fields.
  • Styling uses theme tokens + classnames + *.module.scss; no hard-coded hex.
  • Heavy client-only modules are next/dynamic({ ssr: false }); browser APIs are typeof window guarded.
  • No scattered process.env (all via the Config module).
  • Transport errors handled once in the HTTP interceptor + an ErrorBoundary + a toast/alert channel — not per call site.
  • One socket client owns connect/auth/cleanup; listeners removed in effect cleanup.

Related

  • secure-a-frontend-app — auth/session, route guards, secrets. · handle-files-frontend — CSV/PDF/upload/download.
  • structure-a-frontend-app — where these files live.
  • write-frontend-tests · code-conventions · write-service-code (backend equivalent).

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.