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.tswith shared types - Create
[ComponentName].view.tsxwith pure presentation - Create
[ComponentName].container.tsxwith all logic - Create
index.tsxexporting container
- Create
-
Extract types
- Move component props interface to
types.ts - Create view props interface extending container props
- Export types from
index.tsxfor external use
- Move component props interface to
-
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
- Export container as default from
-
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
useSchemaDatahook - ✅ 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 logicMenuBar- Menu state management, keyboard navigationContextMenu- Positioning logic, click outside handlingRadioGroup- Group state managementToggleGroup- Group state management
Medium Priority (Some Logic)
ContentContainer- Data fetching or transformationRelatedArticles- Data fetching, filtering logicRuleStack- Complex rendering logicLogoWall- Animation or interaction logic
Low Priority (Mostly Presentational)
Button,Avatar,Checkbox,Input,TextArea- Simple presentational componentsSeparator,SectionHeader,SectionNumber- Pure presentationQuoteBlock,QuoteDecor,HeroDecor- Decorative components
Best Practices
- Start with complex components - Components with the most logic benefit most from separation
- Keep it simple - Don't over-engineer simple presentational components
- Maintain backward compatibility - Import paths should remain unchanged
- Test both layers - Test container for logic, view for presentation
- Document the pattern - Add comments explaining non-obvious prop flows
- Use TypeScript strictly - Leverage types to enforce the separation
Additional Resources
Last Updated: April 2025
Maintained by: CommunityRule Development Team