Files

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) => prev to 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).