Files

179 lines
5.3 KiB
Markdown

# 03 - Component Patterns
Established patterns for React components in YouTube Studio Flow. Follow these consistently. For CSS rules, see [[02 - CSS Conventions]]. For backend patterns, see [[04 - Backend Architecture Patterns]].
---
## File structure
Each component lives in two co-located files:
```
ComponentName.tsx
ComponentName.module.css
```
For complex components with internal sub-components, keep everything in one file unless a sub-component is genuinely reused elsewhere. Sub-components that exist only to decompose a large render function are not worth extracting.
---
## forwardRef pattern
Components that expose an imperative API (save, reset, isDirty) use `forwardRef` with a typed handle interface. The primary example is `VideoConfigEditor`.
```typescript
export interface MyComponentHandle {
save(): Promise<void>;
isDirty(): boolean;
}
const MyComponent = forwardRef<MyComponentHandle, Props>((props, ref) => {
useImperativeHandle(ref, () => ({
save: async () => {
// ...
},
isDirty: () => isDirty,
}));
// ...
});
```
The parent calls `configRef.current.save()` from a unified "Save Changes" button. There is no separate save button per sub-component — config and video metadata save together through a single user action.
---
## TanStack Query
### Rules
- Call `useQueryClient()` at the component top level, never inside a callback or effect.
- Query keys are always arrays: `['videos']`, `['video', id]`, `['blocks']`.
- Always invalidate related queries in `onSuccess`.
- Use `placeholderData: (prev) => prev` to keep stale data visible during page transitions and prevent content flash.
### Standard mutation pattern
```typescript
const qc = useQueryClient();
const mut = useMutation({
mutationFn: () => updateVideo(id, form),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['video', id] });
qc.invalidateQueries({ queryKey: ['videos'] });
},
});
```
### Query with stable placeholder
```typescript
const { data } = useQuery({
queryKey: ['videos', page, filters],
queryFn: () => fetchVideos({ page, ...filters }),
placeholderData: (prev) => prev,
});
```
---
## Modal pattern
Use the shared `<Modal>` component for all overlays. Do not implement custom dialog/overlay solutions.
```tsx
import Modal from '@/components/shared/Modal';
{isOpen && (
<Modal title="Edit Block" onClose={() => setIsOpen(false)} width={480}>
{/* modal content */}
</Modal>
)}
```
The `width` prop accepts a pixel number. Standard widths: `480` for forms, `600` for wider editors. The modal handles backdrop click, `Escape` key, and focus trap.
---
## Row-level navigation in tables
For table rows that must support both left-click (navigate via router) and middle-click (open in background tab), use an absolutely positioned anchor overlay inside the first cell:
```tsx
<tr className={styles.clickableRow}>
<td>
<a
href={`/videos/${id}`}
className={styles.rowOverlayLink}
onClick={(e) => {
e.preventDefault();
router.push(`/videos/${id}`);
}}
tabIndex={-1}
aria-hidden="true"
/>
{/* actual cell content */}
</td>
</tr>
```
```css
.clickableRow {
position: relative;
cursor: pointer;
}
.rowOverlayLink {
position: absolute;
inset: 0;
z-index: 1;
}
```
The anchor covers the entire row. Left-click is intercepted by `onClick` and delegates to `router.push` for client-side navigation. Middle-click bypasses the handler entirely, which gives native browser behavior — the link opens in a background tab on Windows. Interactive elements within the row (buttons, checkboxes) sit above the overlay via their own stacking context or `z-index`.
---
## Icon libraries
| Library | Import | Use for |
|---|---|---|
| `lucide-react` | `import { Save, X, AlertCircle } from 'lucide-react'` | General UI icons |
| `react-icons/fa` | `import { FaYoutube } from 'react-icons/fa'` | YouTube icon only |
| `react-icons/si` | `import { SiTwitch, SiInstagram, SiTiktok, SiX, SiBluesky, SiDiscord } from 'react-icons/si'` | Platform brand icons |
**`SiYoutube` does not exist in `react-icons/si` v5.** Always use `FaYoutube` from `react-icons/fa` for the YouTube icon.
---
## Zustand stores
Read specific slices to avoid unnecessary re-renders:
```typescript
// Preferred — subscribe only to what you need:
const toggleSidebar = useUIStore((s) => s.toggleSidebar);
const sidebarCollapsed = useUIStore((s) => s.sidebarCollapsed);
// Also acceptable when you need several values:
const { sidebarCollapsed, toggleSidebar } = useUIStore();
```
Auth state comes from `useAuthStore` (`src/store/useAuthStore.ts`). This store holds the current user, `teamId`, and JWT token. The Axios interceptor in `api-client.ts` reads the token from this store automatically — you do not need to attach it manually to requests.
---
## Sidebar collapse
`Sidebar.tsx` has two display states:
| State | Width | Content |
|---|---|---|
| Expanded | 280px (`--sidebar-width`) | Icon + label for each nav item |
| Collapsed | 64px (`--sidebar-width-collapsed`) | Icon only |
State is persisted to `localStorage` via Zustand's persist middleware under the key `ui-store`. The collapse toggle is a small circular button on the right border of the sidebar, visible only on hover.
When adding new navigation items, provide both the icon (always) and the label (hidden when collapsed via CSS, not conditional rendering).