react-data-layer
Skill18 starsChanged 4 months agoMITView on GitHub
Data layer for React SPAs that talk to a REST API described by an OpenAPI spec - a small fetch-based HTTP client in core/api, a same-origin API base path from validated environment config, Zod schemas and DTO types generated from the committed OpenAPI spec, feature API modules of plain async functions, mappers from DTOs to domain types, request bodies, path and query encoding, file uploads, AbortSignal support, and where each piece lives. Use this skill whenever calling an endpoint, adding an API function, syncing or regenerating the API contract, using a generated DTO schema, mapping API data to domain types, defining domain types, configuring the API base path or other environment variables, or touching the HTTP client. Trigger on phrases like "call the API", "add an endpoint", "API function", "fetch", "HTTP client", "request", "response", "DTO", "Zod schema", "OpenAPI", "spec", "api:sync", "api:generate", "generated", "parse the response", "mapper", "domain type", "base URL", "environment variable", "import.meta.env", "VITE_", "query parameters", "upload a file", "FormData", or "AbortSignal".
- Reads credentials
In this repository67 other files it gives its agents.
- agent-onboardingSkill
- android-backgroundSkill
- android-cliSkill
- android-compose-architectureSkill
- android-compose-componentsSkill
- android-data-layerSkill
- android-di-koinSkill
- android-error-handlingSkill
- android-integration-testingSkill
- android-jacoco-setupSkill
- android-jvm-test-coverageSkill
- android-module-structureSkill
- android-navigationSkill
- android-presentation-mviSkill
- android-preview-figma-verifySkill
- android-ui-screenshot-verifySkill
- android-ui-testingSkill
- android-ui-verifySkill
- android-unit-testingSkill
- android-version-catalogSkill
- architecture-decision-recordSkill
- careful-planningSkill
- compose-adaptive-layoutsSkill
- figma-to-android-vectorSkill
- finalizeSkill
- git-commitSkill
- handoffSkill
- kmp-compose-architectureSkill
- kmp-compose-componentsSkill
- kmp-data-layerSkill
- kmp-di-koinSkill
- kmp-error-handlingSkill
- kmp-integration-testingSkill
- kmp-migration-analyzerSkill
- kmp-module-structureSkill
- kmp-navigationSkill
- kmp-presentation-mviSkill
- kmp-ui-testingSkill
- kmp-unit-testingSkill
- kotlin-coroutinesSkill
- kotlin-flowsSkill
- kotlin-mutation-testingSkill
- openspec-artifact-review-lightSkill
- openspec-artifact-reviewSkill
- project-breakdownSkill
- react-error-handlingSkill
- react-integration-testingSkill
- react-presentation-mviSkill
- react-project-structureSkill
- react-routingSkill
- react-screen-architectureSkill
- react-securitySkill
- react-server-stateSkill
- react-ui-componentsSkill
- react-unit-testingSkill
- speckit-parallelize-agent-teamsSkill
- speckit-parallelize-subagentsSkill
- spring-api-layerSkill
- spring-di-configurationSkill
- spring-integration-testingSkill
- The first 60 shown.
The file
We split this skill into the rules it gives an agent
4 rules. About 0 are common, 3 are distinctive, and 1 is very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈3
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
- Distinctive: line 53
Never invent an endpoint, field, enum value or error code: If the spec doesn't contain what a screen needs, the backend doesn't offer it …
- Distinctive: line 194
Request bodies: use the generated request types.
- Distinctive: line 239
Keep the mapper even when the shapes are identical today.
- Very specific: line 275
Read functions accept an optional AbortSignal and pass it on.
≈ “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.
Files
React Data Layer
Principles
- Plain functions, no repository layer. A feature's
api/folder exports async functions likegetNote(noteId). Query options call them directly (see react-server-state). Tests fake the network with MSW instead of swapping implementations (see react-integration-testing). - The OpenAPI spec is the contract. DTO schemas and types are generated from it, never written by hand. A contract change shows up as a diff in the spec and as compile errors in the mappers.
- Every response is validated. TypeScript types don't exist at runtime. A response that hasn't been parsed with a Zod schema is
unknown, and nothing outside the HTTP client may treat it as anything else. - DTOs stay in the data layer. API functions return domain types from
types.ts. Onlyapi/folders andcore/apiimport generated code. A change in the API's JSON shape is absorbed in one mapper, not across the UI. - Failures are thrown as
DataError. The HTTP client converts every failure into aDataError. Nothing else is thrown out of the data layer (see react-error-handling). fetch, not axios. The HTTP client is a few dozen lines incore/api.
Layout
api/
openapi.json # committed copy of the backend's spec (npm run api:sync)
src/core/api/
generated/ # generated Zod schemas and DTO types (npm run api:generate). Never edited
config.ts # validated environment config (API base path)
httpClient.ts # requestJson / requestVoid, auth header, error conversion
DataError.ts # see react-error-handling
queryClient.ts # see react-server-state
src/features/notes/
types.ts # Note, NoteFilter (domain types)
api/
noteMapper.ts # DTO -> domain, domain -> request body
notesApi.ts # getNotes, getNote, createNote, ...
notesQueries.ts # query keys, queryOptions, mutation hooks (react-server-state)The API Contract
The backend generates its OpenAPI spec from its controllers and DTOs and commits it as a snapshot in app/src/test/resources/openapi/ of the backend repo at /Users/philipplackner/IdeaProjects/cardigan.
| Step | Command | Result |
| Copy the backend's snapshot into this repo | npm run api:sync | api/openapi.json changes |
| Generate schemas and types from the copy | npm run api:generate | src/core/api/generated/ changes |
| Fix what no longer compiles | npm run typecheck | mappers and API functions follow the new contract |
api/openapi.jsonand the generated folder are committed. The spec change and the regenerated code go into one commit.- Never edit generated files. They are excluded from oxlint and Prettier. A wrong schema is fixed in the backend, synced and regenerated.
- Never invent an endpoint, field, enum value or error code. If the spec doesn't contain what a screen needs, the backend doesn't offer it yet. Stop and say what's missing.
- The generator is configured so that every component schema
Xin the spec becomes a Zod schemaxSchemaand a typeX:NoteDto→noteDtoSchema+NoteDto,CreateNoteRequest→createNoteRequestSchema+CreateNoteRequest. - Generated object schemas strip unknown keys (
z.object, notz.strictObject), so a new field in a response doesn't break the app. - Kotlin nullability on the backend is the contract: a non-null property is required, a nullable one is
.nullable(). The generated schema reflects that exactly. - UUIDs are strings with
format: uuid. Instants are strings withformat: date-timein UTC. Both stay strings in DTOs and are turned into domain values in the mapper.
Environment Config
Vite exposes variables prefixed with VITE_ through import.meta.env. They are compiled into the bundle, so every user can read them. Never put a secret there (see react-security).
The app and the API share one origin (see react-routing), so the API base is a path, /api/v1. Tests use an absolute fake origin so MSW handlers match exactly.
// src/vite-env.d.ts
/// <reference types="vite/client" />
// Module augmentation only works with an interface.
// oxlint-disable-next-line typescript/consistent-type-definitions
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string
}// src/core/api/config.ts
const configSchema = z.object({
VITE_API_BASE_URL: z.union([z.string().regex(/^\/[^/]/), z.url()]),
})
const env = configSchema.parse(import.meta.env)
export const apiBaseUrl = env.VITE_API_BASE_URL.replace(/\/$/, '')| File | VITE_API_BASE_URL |
.env (committed) | /api/v1 |
.env.test (committed) | http://api.test/api/v1 |
- Parsing at startup fails fast with a clear error when a variable is missing, instead of requests going to
undefined/notes. - A path must start with a single
/.//hostwould be a protocol-relative URL to another site. - Local overrides go in
.env.local(git-ignored).
HTTP Client (core/api)
// src/core/api/httpClient.ts
type QueryParams = Record<string, string | number | boolean | null | undefined>
type RequestOptions = {
query?: QueryParams
body?: unknown
signal?: AbortSignal
}
export function apiUrl(path: string, query?: QueryParams): string {
// The base is a same-origin path in the app and an absolute URL in tests.
const url = new URL(apiBaseUrl + path, window.location.origin)
for (const [key, value] of Object.entries(query ?? {})) {
if (value != null) url.searchParams.set(key, String(value))
}
return url.toString()
}
export async function requestJson<T>(
method: string,
path: string,
schema: z.ZodType<T>,
options: RequestOptions = {},
): Promise<T> {
const response = await send(method, path, options)
const json: unknown = await response.json().catch((cause: unknown) => {
throw new DataError('serialization', { cause })
})
const result = schema.safeParse(json)
if (!result.success) {
throw new DataError('serialization', { cause: result.error })
}
return result.data
}
export async function requestVoid(
method: string,
path: string,
options: RequestOptions = {},
): Promise<void> {
await send(method, path, options)
}
async function send(method: string, path: string, options: RequestOptions): Promise<Response> {
const isFormData = options.body instanceof FormData
let response: Response
try {
response = await fetchWithAuth(apiUrl(path, options.query), {
method,
signal: options.signal,
headers: isFormData || options.body === undefined ? {} : { 'Content-Type': 'application/json' },
body: isFormData ? options.body : options.body === undefined ? undefined : JSON.stringify(options.body),
})
} catch (cause) {
if (isAbortError(cause)) throw cause
throw new DataError(isTimeoutError(cause) ? 'timeout' : 'network', { cause })
}
if (!response.ok) throw await dataErrorFromResponse(response)
return response
}fetchWithAuthadds the access token and handles a401with one refresh and one retry. Its details depend on the backend (see react-security).dataErrorFromResponsemaps the status (and an error code in the body, if the API sends one) to aDataError(see react-error-handling).- An aborted request rethrows the original
AbortError. TanStack Query aborts requests when a query is cancelled and handles that error itself. - For
FormData, don't setContent-Type. The browser sets it with the multipart boundary. - Nothing else in the app calls
fetchfor the backend API.
Using Generated Schemas
// src/core/api/generated/ (generated from api/openapi.json)
export const noteDtoSchema = z.object({
id: z.uuid(),
title: z.string(),
body: z.string(),
color: noteColorDtoSchema,
updatedAt: z.iso.datetime({ offset: true }),
archivedAt: z.iso.datetime({ offset: true }).nullable(),
})
export type NoteDto = z.infer<typeof noteDtoSchema>
export type CreateNoteRequest = {
title: string
body: string
}- Response schemas are passed to
requestJson. A list endpoint usesz.array(noteDtoSchema)in the API function. - Request bodies use the generated request types. They're built by our own code from typed values, so they aren't validated at runtime.
- Enums from the API are strict in the generated schema, so an unknown value fails as
serialization. The backend treats enum changes as contract changes. If a field must survive an unknown value, extend the schema in the feature's API function, for examplenoteDtoSchema.extend({ color: noteColorDtoSchema.catch('BLUE') }), and say why in a short comment. - Never copy a generated schema into a feature to change it. Extend it where it's used, or fix the spec.
Domain Types and Mappers
// src/features/notes/types.ts
export type NoteColor = 'red' | 'green' | 'blue'
export type Note = {
id: string
title: string
body: string
color: NoteColor
updatedAt: Date
archivedAt: Date | null
}
export type NoteFilter = 'all' | 'archived'// src/features/notes/api/noteMapper.ts
export function toNote(dto: NoteDto): Note {
return {
id: dto.id,
title: dto.title,
body: dto.body,
color: dto.color,
updatedAt: new Date(dto.updatedAt),
archivedAt: dto.archivedAt === null ? null : new Date(dto.archivedAt),
}
}
export function toCreateNoteRequest(values: NoteFormValues): CreateNoteRequest {
return { title: values.title.trim(), body: values.body }
}- Domain types use the forms the UI wants:
Dateinstead of ISO strings, discriminated unions instead of several optional fields, one name per concept even if the API uses another. - Mappers are plain exported functions named
to<Target>. They are pure and unit-tested (see react-unit-testing). - Domain types never import from
api/. - Keep the mapper even when the shapes are identical today. It's the place a future API change gets absorbed.
API Functions
// src/features/notes/api/notesApi.ts
export async function getNotes(filter: NoteFilter, signal?: AbortSignal): Promise<Note[]> {
const dtos = await requestJson('GET', '/notes', z.array(noteDtoSchema), {
query: { archived: filter === 'archived' },
signal,
})
return dtos.map(toNote)
}
export async function getNote(noteId: string, signal?: AbortSignal): Promise<Note> {
const dto = await requestJson('GET', `/notes/${encodeURIComponent(noteId)}`, noteDtoSchema, { signal })
return toNote(dto)
}
export async function createNote(values: NoteFormValues): Promise<Note> {
const dto = await requestJson('POST', '/notes', noteDtoSchema, {
body: toCreateNoteRequest(values),
})
return toNote(dto)
}
export async function deleteNote(noteId: string): Promise<void> {
await requestVoid('DELETE', `/notes/${encodeURIComponent(noteId)}`)
}- One function per endpoint, named after what it does:
getX,getXs,createX,updateX,deleteX, or a domain verb (archiveNote). - Every value interpolated into a path goes through
encodeURIComponent. An ID with a/or?must not change which endpoint is called. - Query parameters go through
query, never string-built. - Read functions accept an optional
AbortSignaland pass it on. Query functions forward the signal TanStack Query provides. - API functions take and return domain types or form values, never DTOs.
Naming Conventions
| Thing | Convention | Example |
| Response schema (generated) | <model>DtoSchema | noteDtoSchema |
| Response type (generated) | <Model>Dto | NoteDto |
| Request body type (generated) | <Verb><Model>Request | CreateNoteRequest |
| Mapper | to<Target> | toNote, toCreateNoteRequest |
| API module | <feature>Api.ts | notesApi.ts |
| API function | verb + model | getNote, createNote, archiveNote |
| Domain type | plain name, no suffix | Note, NoteFilter |
Checklist: Adding an Endpoint
- The endpoint exists in the backend's spec. If not, stop and say what's missing
npm run api:syncandnpm run api:generaterun, spec and generated code committed together- Domain type in the feature's
types.ts(or reuse an existing one) - Mapper in
api/<model>Mapper.tsfrom the generated DTO type, with unit tests for null values and edge cases - API function in
api/<feature>Api.tsusingrequestJson/requestVoid, path params encoded, signal forwarded - Query options or mutation in
api/<feature>Queries.ts(see react-server-state) - MSW handler for the endpoint in the feature's tests (see react-integration-testing)
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
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.

