L’interface est localisée ; les présentations et documents des Skills sont traduits automatiquement par IA dans votre langue. Le code, les commandes, chemins et termes techniques restent en version originale, et le document de l’auteur fait foi.
Explorer les Skills
Design et création · SKILL.md

frontend-remix-patterns

frontend-remix-patterns

Use this skill when the user says 'Remix pattern', 'Remix form validation', 'Remix optimistic UI', 'Remix error boundary', 'Remix SEO', 'Remix caching', 'Remix PWA'. This skill enforces: server-side form validation with Zod, route-level error boundaries, meta exports for SEO, Cache-Control strategies, optimistic updates with useFetcher, and service workers …

Independent GitHub snapshot · Automated classification · Original documentation governs

Documentation de la Skill

GitHub

name: frontend-remix-patterns description: > Use this skill when the user says 'Remix pattern', 'Remix form validation', 'Remix optimistic UI', 'Remix error boundary', 'Remix SEO', 'Remix caching', 'Remix PWA'. This skill enforces: server-side form validation with Zod, route-level error boundaries, meta exports for SEO, Cache-Control strategies, optimistic updates with useFetcher, and service workers for offline support. Requires existing Remix project (package.json with @remix-run/*). Do NOT use for: React-only validation, client-side-only forms. version: "1.0.0" author: "j4flmao" license: "MIT" compatibility: claude-code: true cursor: true codex: true windsurf: true tags: [frontend, remix, react, patterns, phase-7]

Remix Patterns

Purpose

Apply production-grade patterns to Remix applications: server validation, error boundaries per route, SEO metadata, caching, optimistic UI, and PWA support.

Agent Protocol

Trigger

Exact user phrases: "Remix pattern", "Remix form validation", "Remix optimistic UI", "Remix error boundary", "Remix SEO", "Remix caching", "Remix PWA".

Input Context

Before activating, verify:

  • Remix project with @remix-run/react and a routing structure.
  • Whether Zod is already installed for validation.
  • Existing meta/SEO setup.
  • Target deployment platform for caching strategy.

Output Artifact

No file output. Produces code patterns for validation, error handling, SEO, caching, optimistic updates, and PWA.

Response Format

Code examples only. Show action/loader with validation, error boundary, meta export, cache headers.

No preamble. No postamble. No explanations. No filler/hedging/transitions. Compress output — why use many token when few do trick.

Completion Criteria

  • Form validation uses Zod schema in action, returns field errors as JSON.
  • Client-side enhancement shows errors inline from useActionData.
  • ErrorBoundary per route with root fallback.
  • meta function per route with og, twitter, canonical.
  • Cache-Control headers set on loader responses.
  • Optimistic updates use useFetcher with local state + rollback.
  • Service worker registered from public dir with offline fallback.

Max Response Length

Code: 15 lines per example. Unlimited patterns.

Component Architecture / Decision Trees

Architecture Options

ApproachTrade-offWhen to Use
Server-side validation onlySimpler, no JS needed for form to workBasic forms, login, signup
Server + client validation with useActionDataBetter UX, instant feedbackForms with many fields
Optimistic UI with useFetcherInstant feedback, rollback on errorLikes, stars, add to cart
Route ErrorBoundaryScoped error handlingPer-route error recovery
Root ErrorBoundaryGlobal fallbackUnexpected errors, network failures
Meta per routeSEO per pageBlog posts, product pages
Resource route sitemapDynamic sitemap generationSites with dynamic content

Decision Tree: Form Validation

Is the form simple (email + password)?
  ├── Yes -> Server validation with Zod
  └── No (many fields, complex rules) ->
       ├── Server validation (required)
       └── + Client enhancement with useActionData

Decision Tree: Optimistic UI

Does the mutation need instant feedback?
  ├── No -> Standard <Form> with pending state
  └── Yes -> useFetcher + local state
       ├── Can you easily rollback? -> Optimistic update
       └── Risk of race conditions? -> Use useFetcher data for confirmation

Decision Tree: Error Boundary Placement

Is this an expected error (404, 403)?
  ├── Yes -> throw Response in loader, use CatchBoundary (v1) or ErrorBoundary (v2)
  └── No -> ErrorBoundary for unexpected errors
       ├── Route-level -> Scoped error recovery
       └── Root-level -> Global fallback in root.tsx

Decision Tree: Caching Strategy

Is the data user-specific?
  ├── Yes -> private, no-store (never cache)
  └── No -> Is it frequently updated?
       ├── Yes -> max-age=60, s-maxage=300
       └── No -> max-age=3600, s-maxage=86400

Decision Tree: PWA vs Standard

Does the app need offline support?
  ├── No -> Skip service worker, standard web app
  └── Yes -> Service worker with cache-first strategy
       ├── Full offline -> Cache all routes on first visit
       └── Partial offline -> Cache static assets only

Component Design Patterns

Multi-Intent Action

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData()
  const intent = formData.get('intent')

  switch (intent) {
    case 'create': {
      const result = createSchema.safeParse(Object.fromEntries(formData))
      if (!result.success) return json({ errors: result.error.flatten().fieldErrors, intent }, { status: 400 })
      await db.post.create({ data: result.data })
      return redirect('/posts')
    }
    case 'delete': {
      const id = formData.get('id')
      await db.post.delete({ where: { id: String(id) } })
      return json({ ok: true })
    }
    case 'toggle-pin': {
      const id = formData.get('id')
      const post = await db.post.findUnique({ where: { id: String(id) } })
      await db.post.update({ where: { id: String(id) }, data: { pinned: !post?.pinned } })
      return json({ ok: true })
    }
    default:
      throw new Response('Invalid intent', { status: 400 })
  }
}

Sitemap Resource Route

// app/routes/sitemap[.]xml.tsx
import { generateSitemap } from '@remix-run/sitemap'
import { db } from '~/db'

export async function loader({ request }: LoaderFunctionArgs) {
  const posts = await db.post.findMany({ select: { slug: true, updatedAt: true } })
  const postEntries = posts.map(p => ({
    route: `/blog/${p.slug}`,
    lastmod: p.updatedAt.toISOString(),
    changefreq: 'weekly' as const,
    priority: 0.7,
  }))

  return generateSitemap(request, [
    { route: '/', priority: 1.0 },
    { route: '/about', priority: 0.5 },
    ...postEntries,
  ])
}

Meta with Dynamic Data

export const meta: MetaFunction<typeof loader> = ({ data, params, location }) => {
  const product = data?.product
  if (!product) return [{ title: 'Product Not Found' }]

  return [
    { title: product.name },
    { name: 'description', content: product.description?.slice(0, 160) },
    { property: 'og:title', content: product.name },
    { property: 'og:description', content: product.description?.slice(0, 160) },
    { property: 'og:image', content: product.image },
    { property: 'og:url', content: `https://example.com${location.pathname}` },
    { name: 'twitter:card', content: 'summary_large_image' },
    { name: 'twitter:title', content: product.name },
    { tagName: 'link', rel: 'canonical', href: `https://example.com${location.pathname}` },
    { script: [{ type: 'application/ld+json', children: JSON.stringify(productSchema(product)) }] },
  ]
}

Robots.txt Resource Route

// app/routes/robots[.]txt.tsx
export function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url)
  return new Response(
    `User-agent: *\nAllow: /\nSitemap: ${url.origin}/sitemap.xml\nDisallow: /admin`,
    { headers: { 'Content-Type': 'text/plain' } }
  )
}

Scroll Restoration Pattern

// app/root.tsx
import { ScrollRestoration } from '@remix-run/react'

export default function Root() {
  return (
    <html>
      <head />
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  )
}

Pending UI with useNavigation

function GlobalPendingIndicator() {
  const navigation = useNavigation()
  const isPending = navigation.state !== 'idle'

  return (
    <div
      style={{
        position: 'fixed',
        top: 0,
        left: 0,
        right: 0,
        height: 3,
        background: isPending ? 'blue' : 'transparent',
        transition: 'background 0.3s',
        zIndex: 9999,
      }}
    />
  )
}

State Management Patterns

In Remix, most state lives on the server. The patterns below handle the limited client state needed.

Loader Data as State Source

The primary state management pattern: loaders return data, components consume it:

export async function loader({ request }: LoaderFunctionArgs) {
  const userId = await getUserId(request)
  const [profile, notifications, posts] = await Promise.all([
    db.profile.findUnique({ where: { userId } }),
    db.notification.findMany({ where: { userId, read: false } }),
    db.post.findMany({ where: { authorId: userId }, orderBy: { createdAt: 'desc' } }),
  ])
  return json({ profile, notifications, posts })
}

export default function Dashboard() {
  const { profile, notifications, posts } = useLoaderData<typeof loader>()
  return (/* render */)
}

URL Search Params for Filter/Sort/Pagination

export async function loader({ request }: LoaderFunctionArgs) {
  const url = new URL(request.url)
  const filters = {
    search: url.searchParams.get('q') || '',
    category: url.searchParams.get('category') || '',
    sort: url.searchParams.get('sort') || 'date',
    page: Number(url.searchParams.get('page')) || 1,
  }
  // ...
  return json({ products, filters })
}

export default function Products() {
  const { filters } = useLoaderData<typeof loader>()
  const [searchParams, setSearchParams] = useSearchParams()

  return (
    <select
      value={filters.sort}
      onChange={(e) => setSearchParams(prev => {
        prev.set('sort', e.target.value)
        return prev
      })}
    >
      <option value="date">Newest</option>
      <option value="price">Price</option>
    </select>
  )
}

Session Flash Messages

export async function action({ request }: ActionFunctionArgs) {
  const session = await getSession(request.headers.get('Cookie'))
  try {
    await doSomething()
    session.flash('success', 'Done!')
    return redirect('/success', {
      headers: { 'Set-Cookie': await commitSession(session) },
    })
  } catch (e) {
    session.flash('error', 'Failed')
    return redirect('/error', {
      headers: { 'Set-Cookie': await commitSession(session) },
    })
  }
}

// In route component:
export async function loader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request.headers.get('Cookie'))
  return json({
    flash: session.get('success') || session.get('error') || null,
  }, {
    headers: { 'Set-Cookie': await commitSession(session) },
  })
}

useFetcher for Non-Navigation State

function NotificationBell() {
  const fetcher = useFetcher()
  const [count, setCount] = useState(0)

  useEffect(() => {
    if (fetcher.data?.count !== undefined) setCount(fetcher.data.count)
  }, [fetcher.data])

  return (
    <button onClick={() => fetcher.load('/api/notifications/count')}>
      {count} notifications
    </button>
  )
}

Performance Optimization

Server Validation Cost

Zod validation on every action has a cost. For very large forms, consider parsing with .safeParseAsync() and using z.object({...}).parse() only on required fields. Schemas with 20+ fields should be optimized with .strict() to reject unexpected fields.

Cache Strategy

Cache HeaderEffect
public, max-age=300Browser caches for 5 minutes
s-maxage=3600CDN caches for 1 hour
stale-while-revalidate=60Serves stale for 60s while refetching
private, no-storeNever cache (auth routes)

Optimistic UI Performance

Optimistic updates should be lightweight DOM-only changes. Avoid recalculating lists or triggering expensive operations in the optimistic callback.

Error Boundary Cost

ErrorBoundary components are included in the route's client bundle. They are small (1-2KB) but should not contain heavy UI libraries.

Link Prefetching

<Link prefetch="intent" to="/products">Products</Link>     // prefetch on hover/touch
<Link prefetch="render" to="/dashboard">Dashboard</Link>    // prefetch when rendered
<Link prefetch="viewport" to="/contact">Contact</Link>      // prefetch when in viewport
<Link prefetch="none" to="/logout">Logout</Link>            // never prefetch

Build & Bundle Considerations

Build Configuration

// vite.config.ts
import { vitePlugin as remix } from '@remix-run/dev'
import { defineConfig } from 'vite'

export default defineConfig({
  plugins: [remix()],
  build: {
    target: 'es2022',
    sourcemap: false,
    rollupOptions: {
      output: {
        manualChunks: (id) => {
          if (id.includes('node_modules')) return 'vendor'
        },
      },
    },
  },
  server: { port: 3000 },
})

Build Output

build/
  client/       -- Client bundles (JS, CSS, assets)
  server/       -- Server bundle with loaders/actions

Run with: remix-serve build/server/index.js

CSS Strategy

  • Route-level CSS via links export for automatic code splitting
  • Global CSS in app/root.tsx via links export
  • Tailwind: install @tailwindcss/vite plugin, import in root CSS
  • CSS Modules: name files *.module.css, import as import styles from './styles.module.css'

Environment Variables

// Server-only (in loaders/actions): process.env.DATABASE_URL
// Client-exposed: pass through loader
export async function loader() {
  return json({ publicKey: process.env.PUBLIC_STRIPE_KEY })
}

Testing Strategies

Testing Validation Schemas

// __tests__/schemas.test.ts
import { describe, it, expect } from 'vitest'
import { z } from 'zod'

const createProductSchema = z.object({
  name: z.string().min(1, 'Name required'),
  price: z.coerce.number().positive(),
  category: z.enum(['electronics', 'clothing', 'food']),
})

describe('createProductSchema', () => {
  it('accepts valid product data', () => {
    const result = createProductSchema.parse({ name: 'Widget', price: '10', category: 'electronics' })
    expect(result.price).toBe(10)
  })

  it('rejects missing name', () => {
    expect(() => createProductSchema.parse({ price: '10', category: 'electronics' }))
      .toThrow('Name required')
  })

  it('rejects negative price', () => {
    expect(() => createProductSchema.parse({ name: 'Widget', price: '-5', category: 'electronics' }))
      .toThrow()
  })
})

Testing Actions with FormData

// __tests__/settings.action.test.ts
import { describe, it, expect } from 'vitest'
import { action } from '../app/routes/settings'

describe('settings action', () => {
  it('validates name length', async () => {
    const formData = new FormData()
    formData.set('name', 'A')
    formData.set('email', 'test@test.com')

    const response = await action({
      request: new Request('http://localhost/settings', { method: 'POST', body: formData }),
      params: {},
      context: {},
    })

    expect(response.status).toBe(400)
    const data = await response.json()
    expect(data.errors.name).toBeDefined()
  })

  it('redirects on success', async () => {
    const formData = new FormData()
    formData.set('name', 'John')
    formData.set('email', 'john@test.com')

    const response = await action({ /* ... same pattern */ })
    expect(response.status).toBe(302)
    expect(response.headers.get('Location')).toBe('/settings')
  })
})

Testing Error Boundaries

// __tests__/ErrorBoundary.test.tsx
import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest'
import { ErrorBoundary } from '../app/routes/products.$id'

describe('Product ErrorBoundary', () => {
  it('renders error message', () => {
    render(<ErrorBoundary error={new Error('Product not found')} />)
    expect(screen.getByText('Product not found')).toBeDefined()
  })
})

E2E Form Testing

// e2e/contact.spec.ts
import { test, expect } from '@playwright/test'

test('submits contact form without JS', async ({ page }) => {
  await page.goto('/contact')
  await page.fill('[name="email"]', 'test@test.com')
  await page.fill('[name="message"]', 'Hello')
  await page.click('button[type="submit"]')
  await expect(page).toHaveURL(/\/thanks/)
})

test('shows validation errors', async ({ page }) => {
  await page.goto('/contact')
  await page.click('button[type="submit"]')
  await expect(page.locator('[aria-invalid="true"]')).toHaveCount(2)
})

Migration Patterns

Express API to Remix Resource Route

// Before: Express
app.get('/api/products', async (req, res) => {
  const products = await db.product.findMany()
  res.json(products)
})

// After: Remix resource route
export async function loader() {
  return json(await db.product.findMany())
}

React Router SPA to Remix

// Before: SPA with client data fetching
function ProductPage() {
  const { id } = useParams()
  const [product, setProduct] = useState(null)
  useEffect(() => { fetch(`/api/products/${id}`).then(r => r.json()).then(setProduct) }, [id])
  if (!product) return <Spinner />
  return <ProductDetail product={product} />
}

// After: Remix
export async function loader({ params }) {
  const product = await db.product.findUnique({ where: { id: params.id } })
  if (!product) throw new Response(null, { status: 404 })
  return json(product)
}
export default function ProductPage() {
  const product = useLoaderData<typeof loader>()
  return <ProductDetail product={product} />
}

Form Handling Migration

// Before: Client-side fetch
async function handleSubmit(e: React.FormEvent) {
  e.preventDefault()
  const res = await fetch('/api/contact', {
    method: 'POST',
    body: new FormData(e.currentTarget as HTMLFormElement),
  })
  if (res.ok) navigate('/thanks')
}

// After: Remix
export async function action({ request }) {
  const formData = await request.formData()
  // validate and process
  return redirect('/thanks')
}
// Component: <Form method="post">...</Form>

Anti-Patterns

Client-Side Validation Only

// Anti-pattern: validation only on client
function handleSubmit(e) {
  e.preventDefault()
  if (!email.includes('@')) return setError('Invalid email')
  fetch('/api/contact', { method: 'POST', body: new FormData(e.target) })
}

// Correct: validate on server always
export async function action({ request }) {
  const result = schema.safeParse(formData)
  if (!result.success) return json({ errors: result.error.flatten().fieldErrors }, { status: 400 })
}

Loading Data in useEffect

// Anti-pattern: client-side fetch in component
useEffect(() => { fetch('/api/products').then(r => r.json()).then(setProducts) }, [])

// Correct: loader
export async function loader() { return json(await db.product.findMany()) }

Not Returning Proper Status Codes

// Anti-pattern: returning 200 with error
return json({ error: 'Not found' }) // status 200

// Correct
throw new Response('Not found', { status: 404 })

Mixing Client and Server Routes

Don't have overlapping route patterns between Remix routes and external API routes. If /api/products is a Remix resource route, don't also proxy to an external API at the same path.

One Giant Meta Function

// Anti-pattern: all meta in one route
// meta should be per-route, specific to the page content

Missing Focus Management

After form submission with errors, focus should move to the first invalid field. Remix doesn't do this automatically:

useEffect(() => {
  if (actionData?.errors) {
    const firstError = document.querySelector('[aria-invalid="true"]')
    if (firstError instanceof HTMLElement) firstError.focus()
  }
}, [actionData])

Overusing useFetcher for Navigation Mutations

useFetcher does not update the URL. For mutations that should change the URL (create, delete that redirects), use <Form> instead.

Common Pitfalls

Pitfall 1: Client-Only Validation

Client validation is an enhancement. Server validation is mandatory. Always validate in the action.

Pitfall 2: Not Using useActionData for Errors

Returning validation errors without useActionData forces a full page reload and no inline error display. Always return errors as JSON with 4xx status.

Pitfall 3: Caching Authenticated Routes

Never Cache-Control authenticated routes. Use private, no-store or omit the header.

Pitfall 4: One Big Action Function

Using a single action with intent field is cleaner than multiple routes. Wrap in a switch statement for readability.

Pitfall 5: Missing Error Boundaries

Without route-level ErrorBoundary, an error in one component crashes the entire page. Add ErrorBoundary to every layout route at minimum.

Pitfall 6: Not Handling useFetcher Idle State

useFetcher has idle, loading, and submitting states. Check fetcher.state before displaying data to avoid showing stale or undefined values.

Compared With

Remix Form Validation vs React Hook Form

Remix validates on the server natively; React Hook Form is client-first. Remix works without JS; React Hook Form requires JS. For Remix projects, Zod + server validation is the idiomatic approach.

Remix Optimistic UI vs TanStack Query

Both support optimistic updates. Remix's useFetcher approach is simpler (form-based) but less flexible. TanStack Query has richer cache invalidation and retry logic but requires more setup.

Remix Meta vs Next.js Metadata API

Remix's meta export is a function that receives loader data, making it truly dynamic per request. Next.js's generateMetadata is similar but Remix's approach is more explicit about the data dependency.

Ecosystem & Tooling

Core Libraries

LibraryPurpose
zodSchema validation for actions
remix-validated-formDeclarative form validation
@remix-run/nodeSession storage, cookie management
@remix-run/reactClient hooks (useActionData, useFetcher)

SEO Tools

ToolPurpose
@remix-run/sitemapSitemap generation
robots.txt resource routeCrawler directives
JSON-LD in Structured data for rich snippets

Caching Tools

ToolPurpose
Cache-Control headersHTTP caching
CDN (Cloudflare, Fastly)Edge caching
ArcacheRemix cache utility
remix-cacheCache management library

Testing

  • Vitest + React Testing Library for component tests.
  • @remix-run/testing for loader/action unit tests.
  • Playwright for E2E form submission flow.

Community

  • Docs: remix.run/docs
  • GitHub: github.com/remix-run/remix
  • Discord: discord.gg/remix
  • Indie Stack: remix.run/stack/indie

Workflow

Step 1: Form Validation (Zod in Action)

const schema = z.object({ email: z.string().email(), password: z.string().min(8) })
export async function action({ request }: ActionFunctionArgs) {
  const formData = Object.fromEntries(await request.formData())
  const result = schema.safeParse(formData)
  if (!result.success) return json({ errors: result.error.flatten().fieldErrors }, { status: 400 })
  await createUser(result.data)
  return redirect('/dashboard')
}

Use useActionData in the component to display field-level errors. aria-invalid on inputs.

Step 2: Error Handling (ErrorBoundary)

export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
  return <div><h1>Error</h1><p>{error.message}</p></div>
}

Catch boundary for 404/403: throw new Response('Not Found', { status: 404 }). Root error boundary in root.tsx catches all unhandled errors.

Step 3: SEO (Meta + Sitemap)

export const meta: MetaFunction<typeof loader> = ({ data }) => [
  { title: data?.product?.name ?? 'Not Found' },
  { name: 'description', content: data?.product?.description?.slice(0, 160) },
  { property: 'og:title', content: data?.product?.name },
  { tagName: 'link', rel: 'canonical', href: `https://site.com${location.pathname}` },
]

Sitemap as resource route sitemap[.]xml.tsx. Robots.txt as resource route. JSON-LD in component via <script>.

Step 4: Caching

export async function loader({ request }: LoaderFunctionArgs) {
  return json(data, {
    headers: { 'Cache-Control': 'public, max-age=300, s-maxage=3600, stale-while-revalidate=60' },
  })
}

Use s-maxage for CDN cache. stale-while-revalidate for background refresh. No caching for authenticated routes.

Step 5: Optimistic UI

function LikeButton({ postId }: { postId: string }) {
  const fetcher = useFetcher()
  const optimisticLiked = fetcher.formData?.get('liked') === 'true'
  return (
    <fetcher.Form method="post" action="/api/like">
      <input type="hidden" name="liked" value={String(!optimisticLiked)} />
      <button type="submit">{optimisticLiked ? 'Unlike' : 'Like'}</button>
    </fetcher.Form>
  )
}

Rollback: compare fetcher.data with optimistic value; revert on error.

Step 6: PWA

Place sw.js in public/. Register from root.tsx <Scripts> after. Manifest as resource route returning JSON. Offline page as route with service worker cache-first strategy.

Step 7: Pending States with useNavigation

function SubmitButton() {
  const navigation = useNavigation()
  const isPending = navigation.state === 'submitting'
  return <button type="submit" disabled={isPending}>{isPending ? 'Saving...' : 'Save'}</button>
}

Rules

  • Validate on server always. Client validation is enhancement only.
  • Error boundaries are per route with a root fallback.
  • Meta is server-side — dynamic per request, not client-side.
  • Cache public routes aggressively. Never cache authenticated data.
  • Optimistic updates must handle rollback when action fails.
  • Service worker updates should use skip-waiting pattern.
  • Use useNavigation for pending states on submissions.
  • Use useFetcher for non-navigation mutations.
  • Return errors as JSON with 4xx status codes, not redirects.
  • Use Zod for all server-side validation schemas.

References

  • references/remix-data-patterns.md — Remix Data Patterns
  • references/remix-form-patterns.md — Remix Form Patterns
  • references/remix-forms.md — Remix Forms — Validation, Progressive Enhancement, Pending States
  • references/remix-routing.md — Remix Routing & Validation Patterns
  • references/remix-seo.md — Remix SEO — Meta, Sitemap, JSON-LD, Canonical
  • references/remix-validation.md — Remix Form Validation Patterns
  • references/remix-optimistic-ui.md — Remix Optimistic UI Patterns
  • references/remix-error-boundaries.md — Remix Error Boundaries and Error Handling

Handoff

No artifact produced. Next skill: frontend-react-architecture for shared React patterns (component composition, hooks, state management). Carry forward: validation schemas, cache strategy, error boundary patterns.

Skills enregistrées