First pass on component refactor
This commit is contained in:
@@ -0,0 +1,383 @@
|
||||
# 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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
"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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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)
|
||||
|
||||
```typescript
|
||||
"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:**
|
||||
```typescript
|
||||
"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:**
|
||||
```typescript
|
||||
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
|
||||
|
||||
- [React Container/Presenter Pattern](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)
|
||||
- [Separation of Concerns in React](https://react.dev/learn/thinking-in-react)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: April 2025
|
||||
**Maintained by**: CommunityRule Development Team
|
||||
Reference in New Issue
Block a user