The Core Rules - Part 2
Learn rules 4-6: Trial of Three Strains, No Tunnels, and the Coffin Rule (co-location).
Learning Objectives
By the end of this module, you will be able to:
- Design robust interfaces using the Trial of Three Strains
- Avoid prop drilling with explicit prop patterns
- Apply co-location principles effectively
Rule 4: Trial of the Three Strains
The Rule
- Process: Design 3 distinct interface contracts
- Test: Against 3 different scenarios (happy, failure, async)
- Document: Record decision in ADR (Architecture Decision Record)
Analogy: Testing a Bridge
Before building a bridge, engineers test it with different loads:
- Normal load: Expected daily traffic
- Maximum load: Rush hour traffic
- Failure scenario: What happens if a support fails?
Similarly, design your interfaces to handle:
- Happy path: Everything works as expected
- Failure path: What happens when things go wrong
- Async path: Loading states and delayed responses
Why Three Strains?
Designing for three scenarios helps you:
- Discover edge cases early
- Create more robust interfaces
- Avoid breaking changes later
- Build confidence in your design
Exercise: Design 3 Interface Contracts
Scenario: Design an interface for fetching user data
Strain 1: Happy Path
interface UserData {
id: string;
name: string;
email: string;
}
function useUserData(userId: string): UserData {
// Returns user data immediately
}
Strain 2: Failure Path
interface UserDataResult {
data: UserData | null;
error: Error | null;
loading: boolean;
}
function useUserData(userId: string): UserDataResult {
// Handles errors gracefully
}
Strain 3: Async Path
interface UserDataState {
data: UserData | null;
loading: boolean;
error: Error | null;
}
function useUserData(userId: string): UserDataState {
// Handles loading states
}
Final Design: Combine all three:
interface UserDataState {
data: UserData | null;
loading: boolean;
error: Error | null;
}
function useUserData(userId: string): UserDataState {
// Handles happy, failure, and async paths
}
Role-Play: Act Out Scenarios
Scenario 1: Happy Path
- User clicks “Load Profile”
- Data loads successfully
- Profile displays immediately
Scenario 2: Failure Path
- User clicks “Load Profile”
- API returns 404
- Error message displays
- User can retry
Scenario 3: Async Path
- User clicks “Load Profile”
- Loading spinner shows
- Data loads after 2 seconds
- Profile displays
Question: Does your interface handle all three?
Rule 5: No Tunnels
The Rule
- Avoid: Prop drilling through 3+ layers
- Prefer: Explicit props from brain boundary
- Context: Use sparingly for deep trees with stable values
Analogy: Underground Tunnels vs Direct Paths
Prop drilling is like digging tunnels underground — you can’t see where data flows. Explicit props are like direct paths — clear and visible.
- Tunnels (Bad): Data flows through many layers invisibly
- Direct Paths (Good): Data flows directly from source to destination
Why Avoid Prop Drilling?
Prop drilling causes:
- Hard to trace data flow
- Components receive props they don’t use
- Difficult to refactor
- Tight coupling between layers
Exercise: Refactor Prop Drilling Code
Before (Prop Drilling):
// ❌ Prop drilling through 4 layers
function App() {
const user = { id: '1', name: 'John' };
return <Dashboard user={user} />;
}
function Dashboard({ user }) {
return <UserSection user={user} />;
}
function UserSection({ user }) {
return <UserProfile user={user} />;
}
function UserProfile({ user }) {
return <UserCard user={user} />;
}
function UserCard({ user }) {
return <div>{user.name}</div>;
}
After (Explicit Props):
// ✅ Explicit props from brain boundary
function App() {
const user = { id: '1', name: 'John' };
return <DashboardView />;
}
function DashboardView() {
const user = useUser(); // Brain boundary
return (
<Dashboard>
<UserSection>
<UserProfile>
<UserCard user={user} /> {/* Direct from boundary */}
</UserProfile>
</UserSection>
</Dashboard>
);
}
function UserCard({ user }) {
return <div>{user.name}</div>;
}
Quiz: Identify When Context is Appropriate
When should you use Context instead of props?
- ✅ Theme values (stable, used everywhere)
- ✅ Authentication state (stable, deep tree)
- ❌ Form data (changes frequently, shallow tree)
- ❌ API response data (changes frequently)
- ✅ Feature flags (stable configuration)
Rule of thumb: Use Context for stable values that need to be deeply nested. Use props for data that changes frequently.
When Context Makes Sense
// ✅ Good use of Context: Theme (stable, deep)
<ThemeProvider>
<App>
<Dashboard>
<Component>
<DeepComponent /> {/* Needs theme */}
</Component>
</Dashboard>
</App>
</ThemeProvider>
// ❌ Bad use of Context: User data (changes frequently)
<UserProvider>
<App>
<Dashboard user={user} /> {/* Should use props */}
</App>
</UserProvider>
Rule 6: Coffin Rule (Co-location)
The Rule
- Principle: Related code lives together
- If: Only used together → keep together
- Split: By responsibility, not ritual
Analogy: Keeping Tools in the Same Toolbox
When working on a project, you keep related tools together:
- Screws and screwdrivers: Same toolbox
- Paint and brushes: Same area
- Measuring tape and level: Same drawer
Similarly, keep related code together:
- Component and its styles: Same file or folder
- Hook and its tests: Same directory
- Utils used by one feature: With that feature
Why Co-location?
Co-location helps you:
- Find related code quickly
- Understand feature boundaries
- Reduce cognitive load
- Make refactoring easier
Exercise: Reorganize Scattered Code
Before (Scattered):
src/
components/
Button.tsx
Card.tsx
hooks/
useProducts.ts
utils/
formatPrice.ts
calculateTotal.ts
features/
products/
ProductList.tsx
After (Co-located):
src/
components/
Button.tsx
Card.tsx
features/
products/
ProductList.tsx
useProducts.ts
formatPrice.ts
calculateTotal.ts
Benefits:
- All product-related code in one place
- Easier to find and modify
- Clear feature boundaries
Thought Experiment: When Should Code Be Split?
Scenario 1: Shared Utility
// Used by multiple features
function formatCurrency(amount: number): string {
return `$${amount.toFixed(2)}`;
}
Decision: Keep in shared utils/ folder
Scenario 2: Feature-Specific Utility
// Only used by products feature
function calculateProductDiscount(product: Product): number {
// Complex product-specific logic
}
Decision: Keep with products feature
Scenario 3: Component Used Everywhere
// Used across entire app
function Button({ children, onClick }) {
return <button onClick={onClick}>{children}</button>;
}
Decision: Keep in shared components/ folder
Rule: If it’s only used together, keep it together. If it’s shared, put it in a shared location.
Co-location Patterns
Pattern 1: Feature Folder
features/
products/
ProductList.tsx
ProductCard.tsx
useProducts.ts
productUtils.ts
Pattern 2: Component Folder
components/
ProductCard/
ProductCard.tsx
ProductCard.test.tsx
ProductCard.styles.css
Pattern 3: Hook Folder
hooks/
products/
useProducts.ts
useProductFilters.ts
productHooks.test.ts
Creative Application Task
Refactor this code to follow all three rules:
// Current structure (violates all three rules)
function App() {
const [products, setProducts] = useState([]);
return <ProductPage products={products} />;
}
function ProductPage({ products }) {
return <ProductList products={products} />;
}
function ProductList({ products }) {
return products.map(p => <ProductCard product={p} />);
}
function ProductCard({ product }) {
// Uses formatPrice from distant utils folder
return <div>{formatPrice(product.price)}</div>;
}
Your task:
- Design interface for 3 scenarios (happy, failure, async)
- Eliminate prop drilling
- Co-locate related code
Key Takeaways
- Trial of Three Strains: Design interfaces for happy, failure, and async paths
- No Tunnels: Avoid prop drilling; use explicit props or Context appropriately
- Coffin Rule: Keep related code together; split by responsibility, not ritual
Next Steps
Continue to Rules Part 3 to learn about naming conventions and maintaining clean codebases.