Files
community-rule/docs/guides/container-presentation-pattern.md
T
2026-01-29 20:57:39 -07:00

11 KiB

Container/Presentation Pattern

Overview

The Container/Presentation pattern separates component logic from presentation, improving testability, reusability, and maintainability. This pattern is now the standard for complex components in this codebase.

Motivation

Benefits

  • Testability: Pure presentation components can be tested independently with simple prop assertions
  • Reusability: Presentation components can be reused with different data sources or logic
  • Maintainability: Clear separation makes it easier to locate and modify specific concerns
  • Performance: Easier to optimize rendering with React.memo on pure components

When to Use

Use this pattern for components that have:

  • Business logic or state management
  • Data fetching or API calls
  • Analytics tracking
  • Complex event handlers
  • Custom hooks usage
  • Dynamic imports or side effects

Simple presentational components (e.g., Button, Avatar) can remain as single files.

Folder Structure

Each component following this pattern should have this structure:

app/components/[ComponentName]/
  ├── index.tsx                    # Exports container as default
  ├── [ComponentName].container.tsx # Logic, hooks, state management
  ├── [ComponentName].view.tsx     # Pure presentation component
  └── [ComponentName].types.ts     # Shared TypeScript types

File Responsibilities

index.tsx

  • Exports the container component as the default export
  • Optionally exports types for external use
  • Maintains backward compatibility with existing import paths
export { default } from "./AskOrganizer.container";
export type { AskOrganizerProps } from "./AskOrganizer.types";

[ComponentName].container.tsx

Contains all logic:

  • React hooks (useState, useEffect, custom hooks)
  • Event handlers and business logic
  • Data fetching and API calls
  • Analytics tracking
  • State management
  • Computed values and derived state
  • Side effects

Should NOT contain:

  • JSX layout details (beyond composing the view)
  • Inline styles or complex className logic (pass as props)
  • Direct DOM manipulation
"use client";

import { memo } from "react";
import { useAnalytics } from "../../hooks";
import { AskOrganizerView } from "./AskOrganizer.view";
import type { AskOrganizerProps } from "./AskOrganizer.types";

function AskOrganizerContainer(props: AskOrganizerProps) {
  const { trackEvent } = useAnalytics();

  const handleContactClick = () => {
    trackEvent({
      event: "contact_button_click",
      category: "engagement",
      component: "AskOrganizer",
    });
    // ... additional logic
  };

  // Compute derived props
  const variantStyles = computeVariantStyles(props.variant);

  return (
    <AskOrganizerView
      {...props}
      onContactClick={handleContactClick}
      variantStyles={variantStyles}
    />
  );
}

export default memo(AskOrganizerContainer);

[ComponentName].view.tsx

Pure presentation:

  • Receives all data via props
  • Renders JSX based on props
  • No hooks, no state, no side effects
  • Only imports other presentational components

Should NOT contain:

  • useState, useEffect, or any hooks
  • Event handler implementations (receive as callbacks)
  • Data fetching or API calls
  • Analytics tracking
  • Business logic
import ContentLockup from "../ContentLockup";
import Button from "../Button";
import type { AskOrganizerViewProps } from "./AskOrganizer.types";

export function AskOrganizerView({
  title,
  subtitle,
  description,
  buttonText,
  buttonHref,
  variant,
  onContactClick,
  variantStyles,
  ...props
}: AskOrganizerViewProps) {
  return (
    <section className={variantStyles.container}>
      <ContentLockup
        title={title}
        subtitle={subtitle}
        description={description}
      />
      <Button
        href={buttonHref}
        onClick={onContactClick}
      >
        {buttonText}
      </Button>
    </section>
  );
}

[ComponentName].types.ts

  • Shared TypeScript interfaces and types
  • Public props interface (used by consumers)
  • Internal view props (used between container and view)
  • Any utility types specific to the component
export interface AskOrganizerProps {
  title?: string;
  subtitle?: string;
  buttonText?: string;
  buttonHref?: string;
  variant?: "centered" | "left-aligned" | "compact" | "inverse";
  onContactClick?: (data: ContactClickData) => void;
}

export interface AskOrganizerViewProps extends AskOrganizerProps {
  onContactClick: () => void;
  variantStyles: {
    container: string;
    buttonContainer: string;
  };
}

Rules of Thumb

Container Components

DO:

  • Use React hooks (useState, useEffect, custom hooks)
  • Handle all event handlers and business logic
  • Fetch data and manage loading states
  • Track analytics events
  • Compute derived values from props/state
  • Compose the view component with computed props

DON'T:

  • Include complex JSX layout (delegate to view)
  • Mix presentation logic with business logic
  • Access DOM directly (use refs when necessary)

View Components

DO:

  • Receive all data via props
  • Render JSX based on props
  • Import only presentational components
  • Use simple conditional rendering
  • Accept callback props for user interactions

DON'T:

  • Use any React hooks
  • Manage state or side effects
  • Fetch data or make API calls
  • Track analytics directly
  • Implement business logic
  • Access browser APIs directly

Example: AskOrganizer

Before (Monolithic)

"use client";

import { memo } from "react";
import { useAnalytics } from "../hooks";
import ContentLockup from "./ContentLockup";
import Button from "./Button";

const AskOrganizer = memo(({ title, variant, ...props }) => {
  const { trackEvent } = useAnalytics();

  const handleContactClick = () => {
    trackEvent({ event: "contact_click", component: "AskOrganizer" });
  };

  return (
    <section>
      <ContentLockup title={title} />
      <Button onClick={handleContactClick}>Ask an organizer</Button>
    </section>
  );
});

After (Container/Presentation)

AskOrganizer.container.tsx:

"use client";

import { memo } from "react";
import { useAnalytics } from "../../hooks";
import { AskOrganizerView } from "./AskOrganizer.view";
import type { AskOrganizerProps } from "./AskOrganizer.types";

function AskOrganizerContainer(props: AskOrganizerProps) {
  const { trackEvent } = useAnalytics();

  const handleContactClick = () => {
    trackEvent({ event: "contact_click", component: "AskOrganizer" });
  };

  return <AskOrganizerView {...props} onContactClick={handleContactClick} />;
}

export default memo(AskOrganizerContainer);

AskOrganizer.view.tsx:

import ContentLockup from "../ContentLockup";
import Button from "../Button";
import type { AskOrganizerViewProps } from "./AskOrganizer.types";

export function AskOrganizerView({
  title,
  onContactClick,
  ...props
}: AskOrganizerViewProps) {
  return (
    <section>
      <ContentLockup title={title} />
      <Button onClick={onContactClick}>Ask an organizer</Button>
    </section>
  );
}

Migration Checklist

When converting an existing component to this pattern:

  • Identify separation points

    • List all hooks and state management
    • List all event handlers and business logic
    • List all data fetching and side effects
    • Identify pure presentation JSX
  • Create folder structure

    • Create [ComponentName]/ folder
    • Create [ComponentName].types.ts with shared types
    • Create [ComponentName].view.tsx with pure presentation
    • Create [ComponentName].container.tsx with all logic
    • Create index.tsx exporting container
  • Extract types

    • Move component props interface to types.ts
    • Create view props interface extending container props
    • Export types from index.tsx for external use
  • Move presentation to view

    • Copy JSX to view component
    • Remove all hooks and state
    • Replace event handlers with callback props
    • Replace computed values with props
    • Ensure view is a pure function
  • Move logic to container

    • Move all hooks to container
    • Move event handlers to container
    • Move data fetching to container
    • Compute derived props in container
    • Render view component with computed props
  • Update exports

    • Export container as default from index.tsx
    • Export types from index.tsx
    • Delete original component file
    • Verify import paths still work
  • Update tests

    • Verify tests still pass (imports should resolve automatically)
    • Update any tests that relied on implementation details
    • Add tests for view component with mocked props if needed
  • Update Storybook

    • Verify stories still work (imports should resolve automatically)
    • Optionally add view-only stories with mocked props

Refactored Components

The following components have been refactored to use this pattern:

  • AskOrganizer - Analytics tracking and event handlers
  • NumberedCards - Schema generation with useSchemaData hook
  • FeatureGrid - Memoized feature data structures
  • WebVitalsDashboard - Dynamic imports, data fetching, complex state
  • Select - Complex form state management with refs and keyboard navigation

These components serve as reference implementations for the pattern.

Remaining Components

The following components are candidates for future conversion:

High Priority (Complex Logic)

  • Header / HomeHeader - Navigation state, conditional rendering logic
  • MenuBar - Menu state management, keyboard navigation
  • ContextMenu - Positioning logic, click outside handling
  • RadioGroup - Group state management
  • ToggleGroup - Group state management

Medium Priority (Some Logic)

  • ContentContainer - Data fetching or transformation
  • RelatedArticles - Data fetching, filtering logic
  • RuleStack - Complex rendering logic
  • LogoWall - Animation or interaction logic

Low Priority (Mostly Presentational)

  • Button, Avatar, Checkbox, Input, TextArea - Simple presentational components
  • Separator, SectionHeader, SectionNumber - Pure presentation
  • QuoteBlock, QuoteDecor, HeroDecor - Decorative components

Best Practices

  1. Start with complex components - Components with the most logic benefit most from separation
  2. Keep it simple - Don't over-engineer simple presentational components
  3. Maintain backward compatibility - Import paths should remain unchanged
  4. Test both layers - Test container for logic, view for presentation
  5. Document the pattern - Add comments explaining non-obvious prop flows
  6. Use TypeScript strictly - Leverage types to enforce the separation

Additional Resources


Last Updated: April 2025
Maintained by: CommunityRule Development Team