Frontend Development
Advanced React Patterns for Scalable Applications
Master advanced React patterns that will make your applications more maintainable, reusable, and scalable. Learn when and how to use compound components, render props, and custom hooks effectively.
Building scalable React applications requires more than just understanding the basics. As your application grows, you need to implement patterns that promote reusability, maintainability, and clean architecture.
Table of Contents
- Compound Components Pattern
- Render Props Pattern
- Custom Hooks Pattern
- Higher-Order Components
- Context API Patterns
Compound Components Pattern <a id="compound-components" />
The compound components pattern allows you to create components that work together to form a complete UI element. This pattern is particularly useful for building flexible and reusable component APIs.
// Modal compound component example
const Modal = ({ children, isOpen, onClose }) => {
if (!isOpen) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
};
Modal.Header = ({ children }) => <div className="modal-header">{children}</div>;
Modal.Body = ({ children }) => <div className="modal-body">{children}</div>;
Modal.Footer = ({ children }) => <div className="modal-footer">{children}</div>;
// Usage
<Modal isOpen={isOpen} onClose={handleClose}>
<Modal.Header>
<h2>Confirm Action</h2>
</Modal.Header>
<Modal.Body>
<p>Are you sure you want to proceed?</p>
</Modal.Body>
<Modal.Footer>
<button onClick={handleClose}>Cancel</button>
<button onClick={handleConfirm}>Confirm</button>
</Modal.Footer>
</Modal>;
Benefits of Compound Components
- Flexibility: Users can compose the component however they need
- Separation of Concerns: Each sub-component has a specific responsibility
- Implicit State Sharing: Components can share state without prop drilling
Render Props Pattern <a id="render-props" />
The render props pattern involves passing a function as a prop to share code between components. This pattern is excellent for creating reusable logic that can render different UI elements.
// Data fetcher with render props
const DataFetcher = ({ url, render }) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const response = await fetch(url);
const result = await response.json();
setData(result);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchData();
}, [url]);
return render({ data, loading, error });
};
// Usage
<DataFetcher
url="/api/users"
render={({ data, loading, error }) => {
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <UserList users={data} />;
}}
/>;
Custom Hooks Pattern <a id="custom-hooks" />
Custom hooks are the modern way to share stateful logic between components. They encapsulate complex logic and make it reusable across your application.
// Custom hook for API calls
const useApi = (url) => {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const refetch = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await fetch(url);
if (!response.ok) throw new Error("Failed to fetch");
const result = await response.json();
setData(result);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => {
refetch();
}, [refetch]);
return { data, loading, error, refetch };
};
// Usage in component
const UserProfile = ({ userId }) => {
const {
data: user,
loading,
error,
refetch,
} = useApi(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} onRetry={refetch} />;
return <UserCard user={user} />;
};
Advanced Custom Hook Example
// Custom hook for form handling
const useForm = (initialValues, validationSchema) => {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const setValue = useCallback((name, value) => {
setValues((prev) => ({ ...prev, [name]: value }));
}, []);
const setFieldTouched = useCallback((name) => {
setTouched((prev) => ({ ...prev, [name]: true }));
}, []);
const validate = useCallback(() => {
const newErrors = {};
Object.keys(validationSchema).forEach((field) => {
const validator = validationSchema[field];
const error = validator(values[field]);
if (error) newErrors[field] = error;
});
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}, [values, validationSchema]);
const handleSubmit = useCallback(
(onSubmit) => (e) => {
e.preventDefault();
if (validate()) {
onSubmit(values);
}
},
[values, validate],
);
return {
values,
errors,
touched,
setValue,
setFieldTouched,
handleSubmit,
isValid: Object.keys(errors).length === 0,
};
};
Context API Patterns <a id="context-patterns" />
The Context API is perfect for sharing state across multiple components without prop drilling. Here are some advanced patterns for using Context effectively.
// Theme context with custom hook
const ThemeContext = createContext();
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState("light");
const toggleTheme = useCallback(() => {
setTheme((prev) => (prev === "light" ? "dark" : "light"));
}, []);
const value = useMemo(
() => ({
theme,
toggleTheme,
colors: theme === "light" ? lightColors : darkColors,
}),
[theme, toggleTheme],
);
return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
Best Practices and Performance Considerations
1. Memoization Strategies
// Memoize expensive calculations
const ExpensiveComponent = ({ data }) => {
const processedData = useMemo(() => {
return data.map((item) => ({
...item,
computed: expensiveCalculation(item),
}));
}, [data]);
return <DataVisualization data={processedData} />;
};
// Memoize callbacks to prevent unnecessary re-renders
const ParentComponent = () => {
const [count, setCount] = useState(0);
const handleIncrement = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
return <ChildComponent onIncrement={handleIncrement} />;
};
2. Error Boundaries
class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error) {
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
console.error("Error caught by boundary:", error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback || <ErrorFallback error={this.state.error} />;
}
return this.props.children;
}
}
Conclusion
Advanced React patterns are essential tools for building scalable applications. Each pattern serves specific use cases:
- Compound Components: For flexible, composable UI elements
- Render Props: For sharing logic with different rendering needs
- Custom Hooks: For reusable stateful logic
- Context API: For global state management
Choose the right pattern based on your specific needs, and don't be afraid to combine them when it makes sense. The key is to prioritize code reusability, maintainability, and performance.
Remember that with great power comes great responsibility - use these patterns judiciously and always consider the complexity they might add to your codebase.