This document explains the architectural patterns, design philosophy, and decision-making framework for @remoteoss/remote-flows. It complements the implementation details in CLAUDE.md and user-facing documentation in README.md.
Target audience: Maintainers, contributors, and anyone making architectural decisions.
Living Document: This architecture guide evolves as the project grows. When you make an architectural decision, update this document. When a pattern emerges, document it here. When you discover a better way, capture the reasoning for the change.
Understanding the job helps explain why we make certain architectural decisions.
Primary goal: Serve partners (external consumers) by making internal platform features available as an embeddable SDK.
The process:
- Analyze the internal platform - See what's been built internally
- Identify minimal requirements - What's the least we need to ship for partners?
- Expose APIs - Sometimes internal endpoints need to be made public
- Build for partners - Create something they can actually use
- Document thoroughly - How to integrate, what to expect
- Align with partner engineers - Work with Product Design Engineers (PDEs) to understand their needs based on what they've seen in our platform
Ongoing responsibilities:
- ✅ Proactive communication - Tell partners what's changed, what's coming
- ✅ Bug triage & fixes - Respond quickly to partner issues
- ✅ Protect partners - Detect when internal team changes could break partner integrations
- ✅ Maintain revenue - Partners pay for this - keep it working, unlock new features
Multi-dimensional thinking required:
- The SDK - Maintainability, evolution, public API stability
- Backend APIs - Contract between FE and BE, versioning, breaking changes
- Domain knowledge - Understand the HR/employment problem being solved, how it works in our platform
- Partner experience - Make integration easy, reduce friction, minimize breaking changes
Why this matters for architecture:
- "Zero business logic for consumers" → Partners want minimal integration effort
- Breaking changes are expensive → Revenue at risk, partner engineering time
- Feature flags exist → Protect partners while shipping new features
- Documentation is critical → Partners don't have access to our internal knowledge
- Versioning is careful → Different partners move at different speeds
- Communication is key → Partners need advance warning of changes
This context explains why we prioritize stability, backward compatibility, and partner experience over internal code elegance.
- Core Architecture
- Consumer Philosophy
- Form Architecture
- Data & API Patterns
- Authentication & Proxy Pattern
- Customization System
- Testing Philosophy
- Evolving the API
- Example App Patterns
Every consumer application wraps flows in a provider hierarchy:
<RemoteFlows auth={fetchToken} theme={theme} components={customComponents}>
<OnboardingFlow
employmentId='xxx'
render={({ flowBag, components }) => {
// Consumer renders UI using bag + components
}}
/>
</RemoteFlows>Provider chain (outside-in):
ErrorContextProvider- Global error handlingRemoteFlowsErrorBoundary- Catches React errorsQueryClientProvider- Shared React Query clientFormFieldsProvider- Merges consumer + default field componentsRemoteFlowContext- API client instanceThemeProvider- CSS custom properties
Why this order: Errors must catch everything → QueryClient must wrap queries → API client must be available to queries → Theme applies globally.
Every flow exposes a flowBag through the render prop with these standard properties:
{
// Step state machine
stepState: {
currentStep: { name: string; ... },
next: () => void,
previous: () => void,
goToStep: (name: string) => void,
canGoBack: boolean,
isLastStep: boolean,
},
// Form fields for current step
fields: FieldDefinition[],
// Loading states
isLoading: boolean,
isSubmitting: boolean,
// Flow-specific context
countryCode?: string,
employmentId?: string,
// ... other flow-specific data
// Validation methods (async since v1.0.0)
handleValidation: (values: FormValues) => Promise<ParsedValues>,
parseFormValues: (values: FormValues) => Promise<ParsedValues>,
}Contract guarantees:
stepStateis always present in multi-step flowsfieldsis empty array while loading, populated once schemas arriveisLoadingis true until initial data loadsisSubmittingis true during mutation submission- Validation methods are always async (see
MIGRATION.mdfor v1.0.0 breaking change)
Every flow must expose two ways to use it:
import { OnboardingFlow } from '@remoteoss/remote-flows';
<OnboardingFlow
employmentId="xxx"
render={({ flowBag, components }) => (
// Consumer renders with components
)}
/>Use when: Consumer wants a guided experience with prebuilt step components.
import { useOnboarding } from '@remoteoss/remote-flows';
function CustomOnboarding() {
const flowBag = useOnboarding({ employmentId: 'xxx' });
// Consumer builds completely custom UI
return <div>Custom UI using {flowBag.stepState.currentStep.name}</div>;
}Use when: Consumer needs full control over UI/UX and wants only business logic.
Maintenance rule: Both surfaces must stay in sync. The render prop component should internally use the headless hook.
Every flow follows this structure:
src/flows/{FlowName}/
├── index.ts # Public exports only
├── {FlowName}Flow.tsx # Render prop component
├── hooks.tsx # ⚠️ THE BAG LIVES HERE (useFlowName hook)
├── api.ts # React Query hooks/factories
├── context.ts # Flow-specific context (if needed)
├── types.ts # TypeScript types
├── constants.ts # Constants, step names, etc.
├── utils.ts # Pure utility functions
├── components/ # Step components
│ ├── BasicInformationStep.tsx
│ ├── ContractDetailsStep.tsx
│ └── ...
├── json-schemas/ # Static schema fallbacks
│ └── basic-information.json
└── tests/
├── fixtures.ts # Test data
└── *.test.tsx # Tests
Key rules:
hooks.tsxis the source of truth for business logic- Steps are presentation components that consume the bag
- API calls go in
api.ts, never inline in components - No cross-flow imports (flows must be self-contained)
- Public API surface defined in
index.ts(re-exported fromsrc/index.tsx)
These principles guide every architectural decision:
Principle: Consumers should get business logic by default, UI as an option.
Why: Different products have different design systems. A beautiful default UI is worthless if they must override every component. Business logic (API calls, state machines, validation) is universal.
Implementation:
- Always build the headless hook first
- Then build the render prop component on top of it
- Default UI components are lazy-loaded (
lazy-default-components.ts) to keep bundle small - Consumers can use 0%, 50%, or 100% of our UI components
Note on default components: lazy-default-components.ts is considered legacy. Ideally, even our examples should use externally imported default components. This keeps the library truly headless with UI as an optional external dependency.
Principle: Consumers shouldn't think about API calls, state management, or validation for the flow itself.
Why: They're integrating our flows into their products. They want components that "just work," not a toolkit to build flows themselves.
What we handle (flow-internal):
- ✅ API calls within the multi-step form
- ✅ Data fetching & caching for form fields (React Query)
- ✅ Multi-step state machine transitions
- ✅ Form validation (Yup + JSON Schema)
- ✅ Error normalization for mutations
- ✅ Loading/submitting states
What consumers handle:
- ✅ Rendering UI (using our bag + components)
- ✅ Success/error callbacks (
onSuccess,onError)
Good examples (see example/src/):
Onboarding.tsx- Just pass props, render UI, handle callbacksContractorOnboarding.tsx- Same pattern with custom field componentsTermination.tsx- Minimal consumer code, flow handles everything
When consumers DO need business logic:
// After flow completes
<OnboardingFlow
onSuccess={async (result) => {
// Sync to their CRM
await syncToSalesforce(result.employmentId);
// Send to their analytics
await trackEvent('onboarding_complete', { userId, flowType });
// Notify their backend
await fetch('/api/webhooks/onboarding', {
method: 'POST',
body: JSON.stringify(result),
});
}}
/>Why this happens: These are consumer-specific integrations unique to their product/infrastructure.
// Before starting flow
const { canStartOnboarding } = await fetch('/api/check-eligibility', {
body: { userId, planTier }
}).then(r => r.json());
if (!canStartOnboarding) {
showError('Please upgrade your plan to onboard employees');
return;
}
<OnboardingFlow companyId={companyId} type="employee" ... />Why this happens: Eligibility rules vary by consumer (plan limits, feature flags, permissions in their system).
Goal: Minimize these cases. If all consumers need the same data/logic, we should handle it internally.
Principle: Prefer props over component overrides, but offer both.
Decision matrix:
| Customization Need | Solution | Example |
|---|---|---|
| Change text/labels | jsfModify on schema |
"First Name" → "Given Name" |
| Change field styling | CSS classes (RemoteFlows__Input) |
Brand colors, rounded corners |
| Change field behavior | components prop on <RemoteFlows> |
Custom date picker component |
| Change field rendering | x-jsf-presentation in schema |
Custom component for specific field |
| Change validation | Add flow-level prop | validateOnBlur={false} |
| Change step logic | This is our responsibility | Don't make consumers override step components |
Important: Component overrides are controlled by JSON schemas. Steps without schemas (read-only/action steps like "invite" or "review") don't have field-level customization - they're customized via render props and flowBag properties.
What's public API (breaking changes require major version):
- Everything exported from
src/index.tsx - Type signatures of flow props
- The shape of the
flowBag - Step component prop interfaces
- Field component interfaces (
FieldComponentProps)
What's NOT public API (can change in minor/patch):
- Anything in
@remoteoss/remote-flows/internals - Internal component implementations
- File structure under
src/ - Private exports (not in
index.ts)
Additive changes are safe:
- ✅ New optional props on flows
- ✅ New fields in the
flowBag - ✅ New step components
- ✅ New exported utility functions
Breaking changes require major version:
- ❌ Removing props
- ❌ Making optional props required
- ❌ Changing prop types
- ❌ Removing fields from
flowBag - ❌ Changing
handleValidationsignature (see v1.0.0 → async)
When to use feature flags:
Feature flags are used when something could block partners from upgrading:
<OnboardingFlow
options={{
features: ['onboarding_reserves', 'dynamic_steps'],
}}
/>Use feature flags when:
- ✅ Adding a new step that partners must implement UI for (e.g.,
employment_agreement_details) - ✅ Changing navigation behavior that affects their integration
- ✅ New functionality that's opt-in until proven stable
- ✅ Changes that would break versioning/compatibility if forced on everyone
Example - New Step:
// Without feature flag: ALL partners must handle new step immediately
// With feature flag: Partners opt in when they're ready
if (features.includes('employment_agreement_details')) {
steps.push({ name: 'employment_agreement_details', ... });
}When NOT to use feature flags:
- ❌ Internal refactors that don't affect consumer code
- ❌ Bug fixes (deploy immediately)
- ❌ Additive props (already backward compatible)
Feature flag lifecycle:
- New - Opt-in, default
false - Proven - Encourage adoption, still opt-in
- Graduation - Eventually becomes default behavior (major version bump removes flag)
Goal: Feature flags enable gradual rollout without blocking partner upgrades. They're not permanent - they graduate or get removed.
Breaking changes are not fun. They require coordination, documentation, and consumer effort. Always try to find another way first.
Before making a breaking change:
-
Exhaust alternatives:
- Can we make it additive with a new optional prop?
- Can we deprecate gracefully with a warning?
- Can we use a feature flag for gradual migration?
- Can we support both old and new behavior temporarily?
-
Communicate proactively:
- Reach out to known consumers before committing
- Ask questions: "How are you using X?" "Would Y break your integration?"
- Give advance warning: "We're considering changing X in the next major version"
- Gather feedback: Sometimes consumers reveal use cases you didn't consider
-
Document the decision:
- Required: Update
MIGRATION.mdwith:- What changed and why
- Before/after code examples
- Step-by-step migration instructions
- Expected effort (minutes, hours, days?)
- Required: Update CHANGELOG with
BREAKING CHANGE:footer - Recommended: Write a migration guide for complex changes
- Required: Update
-
Provide migration path:
- Support window: How long will we support the old version?
Example - Good breaking change process:
## v2.0.0 Breaking Change: handleValidation is now async
**Why:** Backend validation requires async operations.
**Before:**
```tsx
const parsed = handleValidation(values);
return submitToApi(parsed);
```After:
const parsed = await handleValidation(values);
return submitToApi(parsed);Migration: Add await before handleValidation() calls.
TypeScript will catch missing awaits.
Timeline: v1.x supported until 2024-12-31.
When breaking changes are unavoidable:
- Be honest about the pain
- Provide excellent migration docs
- Support consumers through the upgrade
- Learn from it: What could we have designed differently?
Before making decisions:
- Ask: "Will this force consumers to rewrite code on upgrade?"
- Ask: "Is this solving a real consumer problem or just cleaner for us?"
- Check: Do we have issues/requests from multiple consumers?
When releasing:
- Document breaking changes in
MIGRATION.md - Add migration examples (before/after code)
- Announce in changelog with upgrade path or even offer partners internally pdfs on how to do things
Multi-step flows use useStepState (from src/flows/useStepState.ts), which provides:
const stepState = useStepState({
steps: [
{ name: 'basic_information', validate: true },
{ name: 'contract_details', validate: true },
{ name: 'invite', validate: false }, // Read-only step
],
initialStep: 'basic_information',
});Step transitions happen via:
- User interaction:
stepState.next(),stepState.previous(),stepState.goToStep('step_name') - Mutation success:
onSuccess={() => stepState.next()}after POST/PUT - External navigation: Consumer calls
goToStepbased on URL params
Important: Steps are not just UI—they're state nodes. Some steps (like "invite") don't have forms; they just trigger an action.
Every step's form is defined by a JSON schema:
Static schema (committed to repo):
import basicInfoSchema from './json-schemas/basic-information.json';Dynamic schema (fetched from API):
const { data: schema } = useQuery({
queryKey: ['schema', countryCode],
queryFn: () => fetchSchema(countryCode),
});Why JSON Schema:
- Backend-driven validation - Backend owns validation rules, frontend renders them
- Country-specific fields - Different countries have different required fields
- Consumer customization -
jsfModifylets consumers override labels/descriptions without touching code - Type safety - We generate TypeScript types from schemas
Where schemas live:
- Shared schemas - Sometimes schemas are shared between Platform (Remote.com) and the public API
- Library-specific schemas - Sometimes it's easier to create the schema here rather than exposing a new API endpoint
- Decision criteria: If non-SDK partners (not using the library) will need it, create it in the public API. If it's only for this library, keep it here.
Schema versioning:
The jsonSchemaVersion param controls which version of each schema the API returns:
<OnboardingFlow
options={{
jsonSchemaVersion: {
employment_basic_information: 3, // Use v3 of basic info schema
},
jsonSchemaVersionByCountry: {
DEU: {
contract_details: 4, // Germany uses v4 of contract details
},
CHE: {
contract_details: 2, // Switzerland uses v2
},
},
}}
/>Why versioning:
- Backend can evolve schemas without breaking existing flows
- Different countries can be on different schema versions
- Consumers can opt into new versions when ready
- Gradual rollout of schema changes
Schema shape:
{
"type": "object",
"properties": {
"first_name": {
"type": "string",
"title": "First Name",
"description": "Employee's legal first name",
"minLength": 1
}
},
"required": ["first_name"]
}Each step is a self-contained component:
export function BasicInformationStep({
onSuccess,
onError,
}: {
onSuccess?: (response: ApiResponse) => void;
onError?: (error: { error: Error; fieldErrors: FieldError[] }) => void;
}) {
const { flowBag } = useFlowContext();
const mutation = useMutation({
mutationFn: submitBasicInfo,
onSuccess: (data) => {
flowBag.stepState.next(); // Advance step
onSuccess?.(data);
},
onError: (error) => {
const normalized = isMutationError(error)
? { error, fieldErrors: error.fieldErrors }
: { error: new Error('Unknown'), fieldErrors: [] };
onError?.(normalized);
},
});
return <JsonSchemaForm fields={flowBag.fields} onSubmit={mutation.mutate} />;
}Key patterns:
- Step components don't hold state (state lives in
flowBag) - Step components expose callbacks (
onSuccess,onError) for consumer control - Step components use the mutation pattern (see next section)
Forms are validated using:
- JSON Schema validation (native in
@remoteoss/remote-json-schema-form-kitv1+) - React Hook Form (field-level + form-level validation)
- Backend validation (returned in mutation errors)
Legacy: Older flows may still use Yup or Zod schemas generated from JSON Schema. With @remoteoss/remote-json-schema-form-kit v1+, JSON Schema validation is handled natively without needing Yup/Zod adapters.
Validation flow:
// 1. Get form schema (includes validation from JSON Schema)
const formSchema = useGetSchema(jsonSchema); // Internally calls createHeadlessForm
// 2. React Hook Form with schema validation
const { handleSubmit } = useForm({
// Validation resolver is built into formSchema
});
// 3. Transform values before submission (from formSchema)
const handleValidation = formSchema.handleValidation; // Async function
// 4. Submit to API
const mutation = useMutation({
mutationFn: async (values) => {
const parsed = await handleValidation(values); // Transform via schema
return submitToApi(parsed);
},
});
// 5. Handle backend validation errors
mutation.onError((error) => {
if (isMutationError(error)) {
// Set field errors from backend
error.fieldErrors.forEach(({ field, messages }) => {
setError(field, { message: messages[0] });
});
}
});Key insight: handleValidation comes from the schema (via createHeadlessForm from @remoteoss/remote-json-schema-form-kit), not manually written validation logic. The JSON Schema is the source of truth for validation rules and transformations.
Important: handleValidation and parseFormValues are async since v1.0.0 (see MIGRATION.md).
We use two patterns deliberately:
// api.ts
export const countriesOptions = (client: Client) => {
return queryOptions({
queryKey: ['countries'] as const,
retry: false,
queryFn: async () => {
const response = await getSupportedCountry({ client });
if (response.error || !response.data) {
throw new Error('Failed to fetch countries');
}
return response;
},
});
};
// Usage: consumers can compose with their own options
const { data } = useQuery({
...countriesOptions(client),
select: (response) => response.data?.filter((c) => c.active),
staleTime: 60000,
enabled: isReady,
});Use when:
- Different call sites need different
selecttransformations (either within library flows or by external consumers) - Need to add
enabled,staleTime, or other React Query options at usage site - Query needs to work with
useSuspenseQuery,useQueries, or prefetching
// api.ts
export const useIdentity = () => {
const { client } = useClient();
return useQuery({
queryKey: ['identity'],
queryFn: () => getCurrentIdentity({ client }),
select: (data) => data.data?.data, // Always return this shape
});
};
// Usage: consumers get the same transformation every time
const { data: identity } = useIdentity();Use when:
- The data transformation is always identical across all consumers
- The hook adds business logic beyond just query configuration
- No need for composability (everyone wants the same thing)
Decision guide: If you're unsure, start with queryOptions. It's more flexible and can always be wrapped in a custom hook later.
All API types are generated from OpenAPI specs:
npm run openapi-ts # From production gateway
npm run openapi-ts:local # From local gatewayGenerated files (never hand-edit):
src/client/types.gen.ts- TypeScript typessrc/client/sdk.gen.ts- SDK functionssrc/client/schemas.gen.ts- Zod schemas
Usage:
import { GetEmployment } from '@/src/client/sdk.gen';
import type { Employment } from '@/src/client/types.gen';
const { data } = await GetEmployment({
client,
path: { id: employmentId },
});Linting: .oxlintrc.json ignores src/client/**/*.gen.ts from lint checks.
All mutations use mutationToPromise to normalize errors:
import { mutationToPromise } from '@/src/lib/mutations';
const mutation = useMutation({
mutationFn: mutationToPromise(async (values) => {
return await createEmployment({ client, body: values });
}),
});What mutationToPromise does:
- Wraps SDK calls in try/catch
- Normalizes API errors into
MutationError - Extracts field errors from backend response
- Provides
normalizedErrors,fieldErrors,rawErrorproperties
Don't use plain mutateAsync - always use mutationToPromise or the newer mutateAsyncOrThrow pattern.
Backend errors are normalized via MutationError:
try {
await mutation.mutateAsync(values);
} catch (error) {
if (isMutationError(error)) {
// ✅ Type-safe access to normalized errors
console.log(error.normalizedErrors); // { field_name: { error: [...], source: '...' } }
console.log(error.fieldErrors); // [{ field, messages, userFriendlyLabel }]
console.log(error.rawError); // Original API error
console.log(error.response); // HTTP response
// Example: Handle AI validation errors
const aiError = error.normalizedErrors.services_and_deliverables;
if (aiError?.source === 'remote_ai' && aiError.skippable) {
// Show warning, allow user to skip
}
}
throw error; // Re-throw after handling
}Why the type guard: Without isMutationError(), TypeScript doesn't know the error shape. Always use the guard before accessing error properties.
Steps advance automatically when mutations succeed:
const mutation = useMutation({
mutationFn: mutationToPromise(submitStep),
onSuccess: (response) => {
stepState.next(); // ✅ Step advances automatically
onSuccess?.(response); // ✅ Consumer callback (optional)
},
onError: (error) => {
// ✅ Error is handled, step does NOT advance
onError?.({ error, fieldErrors: [...] });
},
});Key pattern: Steps advance themselves on mutation success. Errors prevent advancement and are surfaced via the onError callback.
Why steps advance automatically:
- Consistent behavior across all flows
- Consumer doesn't need to know step sequencing logic
- Step knows its own success criteria
Consumer callbacks (onSuccess, onError):
onSuccess- For side effects (analytics, showing toasts, redirecting)onError- For displaying errors, but step advancement is already blocked
Exceptions - when consumers control advancement:
// Example: Skip step based on response
onSuccess: (response) => {
if (response.requiresAdditionalVerification) {
stepState.goToStep('verification'); // Manual navigation
} else {
stepState.next(); // Or skip to completion
}
};onError: ({ error, fieldErrors }) => {
if (isMutationError(error) && error.canSkip) {
// Show warning but allow proceeding
setShowSkipOption(true);
}
};
// Later: <button onClick={() => stepState.next()}>Continue Anyway</button>Pattern: Step triggers mutation → mutation succeeds → step advances + consumer notified → mutation fails → step stays + consumer handles error (or conditionally allows skip).
Flows require authentication via the auth prop on <RemoteFlows>:
<RemoteFlows
auth={async () => {
const token = await fetchToken(); // Server-side call
return {
accessToken: token,
expiresIn: 3600, // seconds
};
}}
>
{/* flows */}
</RemoteFlows>Why auth is a function:
- Tokens expire - we call
authagain when the token expires - Server-side fetching - avoid exposing API keys/secrets client-side
- Flexibility - consumers control how they get tokens (cookie, session, etc.)
Consumers can proxy API calls through their backend:
<RemoteFlows
proxy={{
url: 'https://your-backend.com',
headers: { 'x-custom-header': 'value' },
}}
>
{/* flows */}
</RemoteFlows>How it works:
- SDK makes request to
/v1/employments/xxx - SDK sees
proxy.urlis set - SDK rewrites request to
https://your-backend.com/v1/employments/xxx - Your backend:
- Receives request with
x-custom-header - Proxies request to Remote API with
Authorization: Bearer <token> - Returns response to frontend
- Receives request with
Why use a proxy:
- Security: Never expose API keys client-side
Consumers customize appearance via the theme prop:
<RemoteFlows
theme={{
colors: {
primaryBackground: '#ffffff',
primaryForeground: '#364452',
accentBackground: '#e3e9ef',
accentForeground: '#0f1419',
danger: '#d92020',
borderInput: '#cccccc',
},
spacing: '0.25rem',
borderRadius: '0px',
font: { fontSizeBase: '1rem' },
}}
>Note: While theming is available, most consumers prefer to use custom CSS classes as it provides more granular control.
How it works:
applyTheme()(fromsrc/lib/applyTheme.ts) converts theme object to CSS custom properties- Properties are injected into
<style>tag in document head - Components reference properties:
var(--RemoteFlows-primaryBackground)
CSS class naming:
- Prefix:
RemoteFlows__ - Examples:
RemoteFlows__Button,RemoteFlows__Input,RemoteFlows__Select - Variants:
RemoteFlows__Button--primary,RemoteFlows__Button--danger
Consumers can also override with CSS:
.RemoteFlows__Button {
background: blue !important;
}Consumers can replace default field renderers:
import type { FieldComponentProps } from '@remoteoss/remote-flows';
const CustomInput = ({ field, fieldState, fieldData }: FieldComponentProps) => (
<div>
<label htmlFor={field.name}>{fieldData.label}</label>
<input {...field} className="my-custom-input" />
{fieldState.error && <p className="error">{fieldState.error.message}</p>}
</div>
);
<RemoteFlows components={{ text: CustomInput }}>Field types you can override:
text,textarea,email,tel,urlnumber,select,radio,checkboxdate,datetime-local,timefile,hidden
Critical rule: Custom components must spread {...field} onto the input element:
// ✅ CORRECT: Binds React Hook Form
<input {...field} />
// ❌ WRONG: Form won't work
<input value={someValue} onChange={someHandler} />Why: field contains name, value, onChange, onBlur, ref props that React Hook Form needs.
Consumers can override text in JSON schemas without code changes:
<OnboardingFlow
jsfModify={{
modify: {
first_name: {
label: 'Given Name',
description: 'Enter your first name as it appears on your ID',
},
last_name: {
label: 'Family Name',
},
},
}}
/>What can be overridden:
label- Field label textdescription- Help text below fieldplaceholder- Input placeholder
Why this pattern:
- No need to fork/override components
- Centralized text changes (i18n, A/B tests, brand voice)
- Survives library upgrades (schema structure changes don't break overrides)
Implementation: @remoteoss/remote-json-schema-form-kit handles the merging.
What consumers actually do (based on real usage):
- Use
componentsprop - Bring their own input components that match their design system - CSS classes - Override styles via
RemoteFlows__*classes rather thanthemeprop jsfModify- Override labels/descriptions for i18n or brand voice- Minimal theming - Most don't use the
themeprop, preferring direct CSS control
Why this matters:
- The
componentsprop is the primary customization mechanism - CSS classes are more popular than theme tokens
- Simplicity wins: Fewer props, clearer docs, better DX
Current approach: Bundle size limits are monitoring tools, not hard gates.
// .sizelimit.json
{
"@remoteoss/remote-flows": {
"limit": "150kb"
}
}What we monitor:
- Total bundle size via
npm run size - Impact of new dependencies
- Trends over time (is it growing?)
Why not hard limits:
- Sometimes a valuable feature justifies the size increase
- Different flows have different size requirements
- Gives flexibility while maintaining awareness
What to do when bundle grows:
- Understand why - New dependency? Larger schema? More features?
- Is it justified? - Does the value outweigh the cost?
- Can we optimize? - Lazy loading? Tree shaking? Smaller alternative?
Best practices:
- Lazy-load default components (
lazy-default-components.ts) - External dependencies (React, React DOM) aren't bundled
- Split flows into separate entry points (
/flows/*) - Review dependencies before adding them
Goal: Stay aware of bundle size without blocking valuable features.
Four layers, each catching a kind of failure the others can't. A layer earns its place only if it does.
| Layer | Catches | Runs against | When |
|---|---|---|---|
| Unit (schema situations) | How a JSON Schema form behaves: conditional fields, computed or forced values, money conversion, initialValues, jsfModify |
Minimal inline schemas | Every PR |
| Integration | Flow wiring: submitting, going back, moving between steps, error handling | Fixture schemas, endpoints mocked with MSW | Every PR |
| E2E | Whether the SDK and the backend still work together on the happy path | Sandbox | Every PR, nightly |
| Smoke (planned) | Real country schemas using something no situation covers yet | Every country's real schema, in sandbox | Scheduled |
Unit. jsfEngineSituations.ts lists situations: a small schema, what the user does, and what they should see and submit. jsfEngineContract.test.tsx runs each one against every jsf engine and every useHeadlessForm strategy. The idea comes from json-schema-form's own tests: schema in, behaviour out, no flow around it. How to add one: Adding a situation. Plain helper tests, such as dates.test.ts, sit at this layer too.
Integration. Full flows rendered with the endpoints mocked (see 7.3). This is where several things are tested together: a step submits, the next one loads, going back keeps the values.
E2E. Playwright in example/e2e/, driving the deployed example app against the sandbox environment, on every PR and nightly (e2e-nightly.yml). Few tests, happy paths only. When a PR's own unit and integration tests pass but E2E fails, the backend has usually changed.
Smoke (planned, not built yet). Every country's contract details schema, fetched from sandbox. For each one: generate values the schema accepts, run them through useHeadlessForm and parseFormValues, and check that sandbox accepts the payload. Headless, no UI. It runs on a schedule rather than on PRs: running every country is too slow for each PR, and one country's schema changing in sandbox shouldn't block unrelated work. A green run means sandbox schemas work with the SDK; sandbox can differ from production.
Platform's FE already has smoke tests: Playwright, one test per country, run on merge requests against a real backend in CI. Ours would differ in running headless and on a schedule, but three things are worth copying:
- Values are generated from the schema, not written per country: fill the fields, validate, and repeat until the schema accepts them.
- Each country is its own test, so one broken schema doesn't hide the others.
- On failure, the generator is re-run on the saved schema to tell an SDK regression apart from a backend schema regression.
One thing to avoid: about 41 of the ~91 countries in Platform's FE smoke list are commented out, many with "flaky for some reason that is unknown". Skipped countries here should each have a reason and a ticket, so the list shrinks instead of growing.
- Reproducible with a schema alone → a unit situation.
- Needs steps, navigation or API calls → an integration test.
- Needs the real backend → E2E.
- Only some countries break and you don't know which → smoke finds it.
Failures move down the layers. When E2E or smoke finds a schema bug, the fix comes with a situation that reproduces it on a minimal schema. That keeps the slow layers finding new problems instead of guarding old ones.
Vitest globals are enabled in vitest.config.ts:
// vitest.config.ts
export default defineConfig({
test: {
globals: true, // ← This line
},
});Do NOT import test utilities:
// ❌ WRONG
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ✅ CORRECT
describe('MyComponent', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should work', () => {
expect(true).toBe(true);
});
});Why: Globals reduce boilerplate. This is a project convention - follow it.
All API calls are mocked using MSW (Mock Service Worker):
// src/tests/server.ts
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
export const server = setupServer(
http.get('/v1/employments/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, country: { code: 'USA' } });
}),
);Test setup:
// vitest.setup.ts
import { server } from './src/tests/server';
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());Per-test overrides:
it('handles error', async () => {
server.use(
http.get('/v1/employments/:id', () => {
return HttpResponse.json({ error: 'Not found' }, { status: 404 });
}),
);
// Test error handling
});Fixtures: Store mock data in {flow}/tests/fixtures.ts:
// src/flows/Onboarding/tests/fixtures.ts
export const mockEmployment = {
id: 'emp-123',
country: { code: 'USA' },
status: 'pending',
};Always use exact object matching, never expect.objectContaining():
// ❌ BAD: Too loose, hides bugs
expect(mockApiCall).toHaveBeenCalledWith(
expect.objectContaining({ employmentId: 'xxx' }),
);
// ✅ GOOD: Exact match
expect(mockApiCall).toHaveBeenCalledWith({
employmentId: 'xxx',
countryCode: 'USA',
values: { first_name: 'John' },
});Why: objectContaining lets tests pass even if we're passing extra unexpected fields. Strict equality catches bugs.
React Query tests must wrap components in a provider:
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';
describe('useEmployment', () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
});
afterEach(() => {
queryClient.clear(); // ⚠️ IMPORTANT: Clear between tests
});
it('fetches employment', async () => {
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
const { result } = renderHook(() => useEmployment('emp-123'), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockEmployment);
});
});Critical: queryClient.clear() in afterEach prevents test pollution.
When adding features, ask:
- Presentation: UI-only concern (colors, layout, animation)
- → Offer via
themeprop or CSS classes - → Example: Button color variants
- → Offer via
- Business logic: Affects behavior (validation, API calls, state)
- → Handle internally, expose minimal props if needed
- → Example: Auto-save on blur
- Always the same: Fixed behavior for everyone
- → Hardcode it, no prop needed
- → Example: Mutation retry logic
- Sometimes varies: Some consumers need different behavior
- → Add optional prop with sensible default
- → Example:
validateOnBlur?: boolean
- Always varies: Every consumer has different needs
- → Offer component override slot
- → Example: Custom field renderers
- Yes: Additive change (new optional prop/field)
- → Safe to add now
- → Example: New field in
flowBag
- No: Would require removing/changing existing API
- → Think hard before adding
- → Might need major version bump
- → Example: Making optional prop required
// Before
type FlowBag = {
employmentId: string;
countryCode: string;
};
// After: Added new optional field
type FlowBag = {
employmentId: string;
countryCode: string;
jurisdiction?: string; // ← New
};// Before
<OnboardingFlow employmentId="xxx" />
// After: Added new optional prop
<OnboardingFlow
employmentId="xxx"
validateOnBlur={false} // ← New, optional
/>// Before
const handleValidation = (values: FormValues) => ParsedValues;
// After: Changed return type to async
const handleValidation = (values: FormValues) => Promise<ParsedValues>;
// ↑ BREAKING: Consumers using `const result = handleValidation(...)` will break// Before
<OnboardingFlow employmentId="xxx" />
// After: Made prop required
<OnboardingFlow
employmentId="xxx"
countryCode="USA" // ← Now required
/>
// ↑ BREAKING: Existing code without `countryCode` will break// Before
type FlowBag = { employmentId: string };
// After: Renamed field
type FlowBag = { employment_id: string }; // Changed snake_case
// ↑ BREAKING: Code accessing `flowBag.employmentId` will breakPublic API (src/index.tsx):
export { OnboardingFlow } from './flows/Onboarding';
export { useOnboarding } from './flows/Onboarding/hooks';
export type { OnboardingFlowBag } from './flows/Onboarding/types';- Semver guarantees apply
- Breaking changes require major version
- Full TypeScript documentation
Internals (src/internals.tsx):
export { parseStepValues } from './lib/utils';
export { useStepState } from './flows/useStepState';
export { mutationToPromise } from './lib/mutations';- No semver guarantees
- Can change in any version
- For advanced consumers who need low-level access
- Documented with "
⚠️ Internal API - may change without notice"
When to use internals:
- Experimental features we're not ready to commit to
- Low-level utilities that most consumers don't need
- Implementation details that might need to change
Analysis:
- Presentation concern? Yes (button state)
- Business logic concern? Partly (we control
isSubmitting) - Does it vary? No, everyone wants this
Decision: Make default SubmitButton auto-disable when isSubmitting. No prop needed.
export function SubmitButton({ children, disabled, ...props }) {
const { flowBag } = useFlowContext();
return (
<button
disabled={disabled || flowBag.isSubmitting} // ← Auto-disable
{...props}
>
{children}
</button>
);
}Why: This is sensible default behavior. If a consumer wants different behavior, they can override the SubmitButton component.
Analysis:
- Presentation? No
- Business logic? Yes (routing)
- Does it vary? Yes (different apps have different routing patterns)
- Can we handle internally? No (we don't know their routing)
Decision: Expose goToStep in the bag, let consumer handle routing:
function MyFlow() {
const { flowBag } = useOnboarding({ employmentId });
const [searchParams] = useSearchParams();
useEffect(() => {
const step = searchParams.get('step');
if (step) flowBag.stepState.goToStep(step);
}, [searchParams]);
// ...
}Why: Routing is consumer's responsibility. We provide primitives (goToStep), they wire it up.
The example/ app demonstrates integration patterns. These are not part of the library—they're reference implementations showing best practices for consuming the library.
Good examples: example/src/Onboarding.tsx, example/src/ContractorOnboarding.tsx
Steps should be dynamic and generated from the hooks/bag, not hardcoded:
// ✅ GOOD: Dynamic steps from the bag
const OnBoardingRender = ({ onboardingBag, components }) => {
const currentStepIndex = onboardingBag.stepState.currentStep.index;
return (
<>
<div className='steps-navigation'>
<ul>
{onboardingBag.steps
.filter((step) => step.visible)
.map((step, index) => (
<li
key={step.name}
className={`step-item ${step.index === currentStepIndex ? 'active' : ''}`}
>
{index + 1}. {step.label}
</li>
))}
</ul>
</div>
{/* Render current step */}
</>
);
};// ❌ BAD: Hardcoded steps
const STEPS = ['Select Country', 'Basic Information', 'Contract Details'];
{
STEPS.map((step, index) => <li key={index}>{step}</li>);
}Why dynamic steps:
- Steps can be conditionally shown/hidden - Based on country, product type, feature flags
- Step labels come from SDK - Can be updated if necessary by consumers
- Step order can change - Without breaking the UI
How it works:
Steps are generated in src/flows/{FlowName}/hooks.tsx:
// hooks.tsx - Steps are defined here
const steps = [
{ name: 'select_country', label: 'Select Country', visible: true, index: 0 },
{
name: 'basic_information',
label: 'Basic Information',
visible: true,
index: 1,
},
{
name: 'contract_details',
label: 'Contract Details',
visible: !!countryCode,
index: 2,
},
// ...
];
// Returned in the bag
return {
steps,
stepState,
// ...
};Consumers map over flowBag.steps to render navigation UI.
Example apps follow this naming pattern for clarity:
// Top-level export (what consumers import)
export function OnboardingForm() {
return (
<RemoteFlows {...auth}>
<OnboardingWithProps {...formData} />
</RemoteFlows>
);
}
// Inner component: renders the flow
function OnboardingWithProps({ companyId, type }) {
return (
<OnboardingFlow
companyId={companyId}
type={type}
render={OnBoardingRender}
/>
);
}
// Render function: receives bag and components
function OnBoardingRender({ onboardingBag, components }) {
// Render UI using bag + components
}Why this structure:
- Clear separation: auth setup → flow props → render logic
- Easy to test each layer independently
- Shows consumers how to compose the library
Example apps use a shared error type and display component:
type Errors = {
apiError: string;
fieldErrors: {
field: string;
messages: string[];
userFriendlyLabel: string;
}[];
};
const [errors, setErrors] = useState<Errors>({ apiError: '', fieldErrors: [] });
<StepComponent
onError={(e) => setErrors({
apiError: e.error.message,
fieldErrors: e.fieldErrors
})}
/>
<AlertError errors={errors} />Why this pattern:
- Separates API errors from field errors
- Lets consumer map field names to user-friendly labels
- Shared
AlertErrorcomponent displays both types - Shows one way to handle errors (consumers can choose their own pattern)
Not part of library: Error display is consumer responsibility. Examples show one approach.
This architecture guide captures the patterns, philosophy, and decision-making framework for @remoteoss/remote-flows.
Key takeaways:
- Headless-first - Business logic by default, UI as an option
- Zero consumer complexity - Handle API calls, state, validation internally
- Flexible customization - Props for common needs, component overrides for edge cases
- Stable public API - Additive changes preferred, breaking changes minimized
- Two React Query patterns -
queryOptionsfor flexibility, custom hooks for fixed transformations - Proxy pattern for auth - Server-side token minting, header-based scoping
- Multi-step state machine -
useStepStateprimitive, mutations advance steps - Test with globals - Vitest globals enabled, MSW for mocking, strict equality assertions
When making decisions, ask:
- Does this force consumers to rewrite code?
- Do we have to explain to the consumer, how to use it and its cumbersome?
- Can we add it later without breaking changes?
- Should this be a prop or a component override?
For questions not covered here, see:
CLAUDE.md- Implementation detailsREADME.md- User-facing documentationMIGRATION.md- Breaking changes and upgrade pathsdocs/COMPONENT_CUSTOMIZATION.md- Field component overrides