5.3 KiB
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.
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) => prevto keep stale data visible during page transitions and prevent content flash.
Standard mutation pattern
const qc = useQueryClient();
const mut = useMutation({
mutationFn: () => updateVideo(id, form),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['video', id] });
qc.invalidateQueries({ queryKey: ['videos'] });
},
});
Query with stable placeholder
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.
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:
<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>
.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:
// 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).