01 - Frontend Patterns (React, MUI, TypeScript)¶
Component Architecture¶
Functional Components (FC) with TypeScript¶
All components are functional components with explicit type annotations.
import { FC, ReactNode } from 'react';
import Box from '@mui/material/Box';
type MyComponentProps = {
children: ReactNode;
disabled?: boolean;
};
export const MyComponent: FC<MyComponentProps> = ({ children, disabled = false }) => {
return (
<Box sx={{ /* styles */ }}>
{children}
</Box>
);
};
Rules:
- Use FC type for functional components
- Define props as a separate type (not inline)
- Props type name: <ComponentName>Props
- Export components explicitly
- Use named exports (not default)
Component Organization¶
Page Components¶
Pages are in pages/<domain>/ and orchestrate features:
import { FC } from 'react';
import { useListBricksQuery } from '../../services/brickApi';
import { BrickForm } from '../../components/BrickForm';
import { BrickTable } from '../../components/BrickTable';
export const BrickPage: FC = () => {
const { data, loading, error } = useListBricksQuery();
return (
<div>
<BrickForm onSubmit={/* ... */} />
<BrickTable items={data?.items ?? []} loading={loading} />
</div>
);
};
Responsibilities: - Fetch data from API services - Manage page-level state (e.g., filters) - Compose multiple components - Handle navigation
Feature Components¶
Reusable domain-specific components in components/<domain>/:
// BrickForm.tsx - form for creating/editing a brick
type BrickFormProps = {
onSubmit: (data: BrickCreationForm) => Promise<void>;
loading?: boolean;
};
export const BrickForm: FC<BrickFormProps> = ({ onSubmit, loading = false }) => {
// Component logic
};
// BrickCard.tsx - displays a single brick
type BrickCardProps = {
brick: Brick;
onDelete?: (id: string) => void;
};
export const BrickCard: FC<BrickCardProps> = ({ brick, onDelete }) => {
// Component logic
};
Rules: - Components are single-responsibility - Props are immutable (no mutation) - Callbacks passed as props - No direct routing calls (pages handle navigation)
Presentational Components¶
Generic, reusable components in components/:
State Management¶
Redux Toolkit + RTK Query¶
Redux Toolkit manages app-wide state. RTK Query handles server state (caching, refetching).
Store Setup¶
// store.ts
import { configureStore } from '@reduxjs/toolkit';
import { emptySplitApi } from './services/emptyApi';
import { apiErrorSnackbarReducer } from './features/apiErrorSnackbar/slice';
import { rtkQueryErrorSnackbarMiddleware } from './middleware/rtkQueryErrorSnackbarMiddleware';
export const store = configureStore({
reducer: {
apiErrorSnackbar: apiErrorSnackbarReducer,
[emptySplitApi.reducerPath]: emptySplitApi.reducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(rtkQueryErrorSnackbarMiddleware, emptySplitApi.middleware),
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;
Feature Slices¶
Local domain state in features/<domain>/:
// features/menuItem/slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
type MenuItemState = {
selectedId: string | null;
editMode: boolean;
};
const initialState: MenuItemState = {
selectedId: null,
editMode: false,
};
export const menuItemSlice = createSlice({
name: 'menuItem',
initialState,
reducers: {
selectItem: (state, action: PayloadAction<string>) => {
state.selectedId = action.payload;
},
toggleEditMode: (state) => {
state.editMode = !state.editMode;
},
},
});
export const { selectItem, toggleEditMode } = menuItemSlice.actions;
export const menuItemReducer = menuItemSlice.reducer;
Rules:
- One slice per domain feature
- Use PayloadAction<T> for type safety
- Keep slices small (avoid mega-reducers)
- Redux for UI state (not server state)
Generated API Services¶
API services are auto-generated from OpenAPI specs:
// Generated: services/brickApi.ts
import { emptySplitApi } from './emptyApi';
export const brickApi = emptySplitApi.injectEndpoints({
endpoints: (builder) => ({
listBricks: builder.query<BrickListResponse, void>({
query: () => '/brick',
}),
getBrickById: builder.query<Brick, string>({
query: (id) => `/brick/${id}`,
}),
createBrick: builder.mutation<Brick, BrickCreationForm>({
query: (form) => ({
url: '/brick',
method: 'POST',
body: form,
}),
invalidatesTags: ['Brick'], // Auto-refetch list
}),
}),
});
export const { useListBricksQuery, useGetBrickByIdQuery, useCreateBrickMutation } = brickApi;
CRITICAL: Generated files are read-only. Do not manually edit.
To regenerate after API changes:
Additional generation rules:
- Always run openapi:pull before rtk:codegen to avoid stale endpoints.
- Every domain API expected to be generated must be declared in data-web/openapi-config.ts under outputFiles (for example ./src/services/androidApi.ts).
- If a generated endpoint is wrong, update backend OpenAPI source and/or openapi-config.ts, then regenerate. Never patch generated files directly.
Form Handling with React Hook Form¶
Forms use react-hook-form + MUI components + zod (optional validation).
import { useForm, useFormContext, FormProvider } from 'react-hook-form';
import Stack from '@mui/material/Stack';
import TextField from '@mui/material/TextField';
import Button from '@mui/material/Button';
type BrickFormValues = {
number: string;
title: string;
tags: string[];
imageBase64?: string;
};
export const BrickForm: FC<{ onSubmit: (data: BrickFormValues) => Promise<void> }> = ({ onSubmit }) => {
const methods = useForm<BrickFormValues>({
defaultValues: {
number: '',
title: '',
tags: [],
},
});
return (
<FormProvider {...methods}>
<form onSubmit={methods.handleSubmit(onSubmit)}>
<Stack spacing={2}>
<BrickNumberField />
<BrickTitleField />
<BrickTagsField />
<Button type="submit" variant="contained">
Save
</Button>
</Stack>
</form>
</FormProvider>
);
};
// Field component (extracts logic)
const BrickNumberField: FC = () => {
const {
register,
formState: { errors },
} = useFormContext<BrickFormValues>();
return (
<TextField
label=\"Brick Number\"
fullWidth
error={Boolean(errors.number)}
helperText={errors.number?.message ?? 'Unique brick reference.'}
{...register('number', {
required: 'Number is required.',
validate: (value) => value.trim().length > 0 || 'Number cannot be empty.',
})}
/>
);
};
Rules:
- Use FormProvider to share form state across nested components
- Extract field components to keep forms readable
- Validation in register() or via external schema
- Use MUI's error and helperText props for error display
- Loading states managed via useFormState() or dedicated Redux slice
Responsive Design (Mobile-First)¶
MUI sx Prop for Styling¶
Use sx prop for inline styles (preferred):
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', md: 'row' },
gap: 2,
}}
>
{/* Children */}
</Box>
Desktop Table vs Mobile Card View¶
Desktop:
import { DataGrid, GridColDef } from '@mui/x-data-grid';
const columns: GridColDef[] = [
{ field: 'id', headerName: 'ID', width: 100 },
{ field: 'title', headerName: 'Title', width: 300 },
// ...
];
export const BrickTable: FC<{ bricks: Brick[] }> = ({ bricks }) => (
<DataGrid rows={bricks} columns={columns} />
);
Mobile:
export const BrickCardList: FC<{ bricks: Brick[] }> = ({ bricks }) => (
<Stack spacing={2}>
{bricks.map((brick) => (
<BrickCard key={brick.id} brick={brick} />
))}
</Stack>
);
Page-level Decision:
import { useMediaQuery } from '@mui/material';
export const BrickListPage: FC = () => {
const isDesktop = useMediaQuery('(min-width: 960px)');
const { data } = useListBricksQuery();
return isDesktop ? (
<BrickTable bricks={data?.items ?? []} />
) : (
<BrickCardList bricks={data?.items ?? []} />
);
};
Rules:
- Mobile-first: stack vertically, minimal horizontal scrolling
- Use MUI breakpoints (xs, sm, md, lg, xl)
- Maintain feature parity between views
- Touch-friendly targets (buttons ≥ 48px)
List Printing UX (Required)¶
All data list pages must provide a print action with two modes: - Print filtered results (uses current search/filter/sort context) - Print all results (ignores active filters when allowed by business rules)
Recommended implementation rules:
- Add a visible Print action in page-level toolbar
- Keep print output aligned with backend response to avoid data mismatch
- Use a print-focused view (@media print) that hides navigation/actions
- Include print metadata: domain title, timestamp, and printed total
type PrintMode = 'filtered' | 'all';
const handlePrint = async (mode: PrintMode) => {
const payload = {
mode,
filters: mode === 'filtered' ? activeFilters : undefined,
sort: activeSort,
};
const response = await printList(payload).unwrap();
setPrintData(response);
window.print();
};
Form Printing UX (Required)¶
For domains with data-entry workflows, provide blank paper forms for pre-entry data collection (data collected on paper before being typed into the software).
Two form types must be supported:
- Unitaire (formulaire vierge): a blank form with all fields and write lines for a single record
- Listing (listing vierge): a blank table with column headers and empty rows for batch entry of multiple records
Implementation rules:
- Add Formulaire vierge and Listing vierge buttons in the page toolbar
- Both are generated client-side as HTML strings and opened in a new browser tab — no API call required
- Unit form: use labeled field rows with a write line below each label
- Listing form: use a table with column headers and 20 empty rows by default
- Include a title, domain name, and print date in the form header
- Apply @media print CSS to hide browser chrome and optimize margins
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function printUnitFormHtml(title: string, fields: string[]): string {
const date = new Date().toLocaleDateString('fr-FR');
const fieldRows = fields
.map((f) => `<div class="field"><label>${escapeHtml(f)} :</label><span class="line"></span></div>`)
.join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${escapeHtml(title)}</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; }
h1 { margin: 0 0 4px 0; font-size: 18px; }
.meta { margin-bottom: 24px; color: #666; font-size: 12px; }
.field { margin-bottom: 20px; }
label { display: block; font-size: 13px; color: #333; margin-bottom: 4px; }
.line { border: none; border-bottom: 1px solid #999; width: 100%; height: 28px; display: block; }
@media print { body { margin: 12mm; } }
</style>
</head>
<body>
<h1>${escapeHtml(title)}</h1>
<div class="meta">Imprime le : ${escapeHtml(date)}</div>
${fieldRows}
</body>
</html>`;
}
function printListingFormHtml(title: string, columns: string[], rowCount = 20): string {
const date = new Date().toLocaleDateString('fr-FR');
const headerCells = columns.map((c) => `<th>${escapeHtml(c)}</th>`).join('');
const emptyRow = `<tr>${columns.map(() => '<td> </td>').join('')}</tr>`;
const emptyRows = Array.from({ length: rowCount }, () => emptyRow).join('');
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>${escapeHtml(title)}</title>
<style>
body { font-family: Arial, sans-serif; margin: 24px; }
h1 { margin: 0 0 4px 0; font-size: 18px; }
.meta { margin-bottom: 16px; color: #666; font-size: 12px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #999; padding: 8px; text-align: left; height: 28px; }
th { background: #f5f5f5; font-size: 13px; }
td { font-size: 12px; }
@media print { body { margin: 12mm; } }
</style>
</head>
<body>
<h1>${escapeHtml(title)}</h1>
<div class="meta">Imprime le : ${escapeHtml(date)}</div>
<table>
<thead><tr>${headerCells}</tr></thead>
<tbody>${emptyRows}</tbody>
</table>
</body>
</html>`;
}
// Open form in a new tab for printing
const handlePrintForm = (html: string) => {
const blob = new Blob([html], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
};
MUI v9 Typography & Styling¶
import Typography from '@mui/material/Typography';
// GOOD: Use sx for fontWeight
<Typography sx={{ fontWeight: 600 }}>Bold text</Typography>
// AVOID: Direct prop fontWeight (TS overload issues)
// <Typography fontWeight={600}>Bold</Typography>
Hooks & Custom Logic¶
Custom Hooks Pattern¶
Extract reusable logic into custom hooks:
// useSectionFormControllers.ts
export const useSectionFormControllers = () => {
const [editingId, setEditingId] = useState<string | null>(null);
const { data: sections } = useListSectionsQuery();
const [createSection] = useCreateSectionMutation();
const [updateSection] = useUpdateSectionMutation();
const handleCreate = async (form: SectionCreationForm) => {
await createSection(form).unwrap();
};
return {
editingId,
setEditingId,
sections,
handleCreate,
// ...
};
};
Rules:
- Custom hooks prefix with use
- Encapsulate related state + effects
- Return object for easy extension
- No side effects on initial call (wrap in useEffect)
Error Handling¶
Errors are captured by RTK Query and displayed via Redux middleware:
// middleware/rtkQueryErrorSnackbarMiddleware.ts
// Auto-catches API errors, dispatches snackbar action
// Usage: Any API error → snackbar appears automatically
Manual error handling:
const [createBrick] = useCreateBrickMutation();
const handleSubmit = async (form: BrickCreationForm) => {
try {
await createBrick(form).unwrap();
// Success
} catch (error) {
// Handle error (already shown in snackbar)
console.error('Failed to create brick:', error);
}
};
Accessibility (A11y)¶
Required for all components:
- Semantic HTML (<button>, <form>, <label>)
- ARIA labels for icons: <IconButton aria-label="delete" />
- Color not only distinguisher (use patterns + text)
- Keyboard navigation support (MUI buttons by default)
- Focus visible indicators
- Alt text for images
import IconButton from '@mui/material/IconButton';
import DeleteIcon from '@mui/icons-material/Delete';
<IconButton
aria-label="delete brick"
onClick={() => deleteBrick(id)}
>
<DeleteIcon />
</IconButton>
Testing Components¶
Test files colocated with components or in __tests__ folder:
// BrickForm.test.tsx
import { render, screen } from '@testing-library/react';
import { BrickForm } from './BrickForm';
describe('BrickForm', () => {
it('should display the brick number field', () => {
render(<BrickForm onSubmit={vi.fn()} />);
expect(screen.getByLabelText(/brick number/i)).toBeInTheDocument();
});
});
See Testing Conventions for full details.