Architecture Patterns for Composable React Hooks in Next.js 15 with TypeScript (2026)
As React continues to evolve, especially looking ahead to Next.js 15 in 2026, the emphasis on composable patterns remains paramount. Functional components and Hooks have fundamentally reshaped how we manage state and side effects. This article delves into architectural strategies for crafting highly composable custom hooks using TypeScript, ensuring our applications are scalable, maintainable, and aligned with future best practices. We will focus on the foundational useState hook, demonstrating its power through practical, real-world examples.
1. Embracing useState for Composable State Management
The useState hook is the cornerstone of state management in React functional components. It allows us to declare state variables in a component and provides a function to update them. For composable architectures, the real power of useState shines when it's encapsulated within custom hooks, abstracting state logic away from UI concerns. Let's start with a simple, yet incredibly useful custom hook: useToggle.
useToggle abstracts the common pattern of managing a boolean state, such as for visibility, selection, or enablement. By wrapping useState, we create a reusable piece of logic that can be consumed across any component or even within other custom hooks.
// hooks/useToggle.ts
import { useState, useCallback } from 'react';
/**
* @interface UseToggleResult
* @description Defines the return type for the useToggle hook.
* @property {boolean} isOpen - The current boolean state.
* @property {() => void} toggle - Function to invert the current state.
* @property {() => void} setTrue - Function to set the state to true.
* @property {() => void} setFalse - Function to set the state to false.
*/
interface UseToggleResult {
isOpen: boolean;
toggle: () => void;
setTrue: () => void;
setFalse: () => void;
}
/**
* @function useToggle
* @description A custom hook for managing a boolean state with toggle, setTrue, and setFalse actions.
* @param {boolean} [initialState=false] - The initial boolean state.
* @returns {UseToggleResult} An object containing the current state and control functions.
*/
export function useToggle(initialState: boolean = false): UseToggleResult {
const [isOpen, setIsOpen] = useState<boolean>(initialState);
// Memoize functions to prevent unnecessary re-renders in consuming components
const toggle = useCallback(() => setIsOpen(prev => !prev), []);
const setTrue = useCallback(() => setIsOpen(true), []);
const setFalse = useCallback(() => setIsOpen(false), []);
return { isOpen, toggle, setTrue, setFalse };
}
In this example, useState(initialState) initializes our boolean state. The toggle, setTrue, and setFalse functions are wrapped in useCallback to ensure referential stability. This is a critical pattern for performance, preventing unnecessary re-renders of child components that depend on these functions, thereby enhancing composability without performance penalties.
2. Composing with useToggle: The useModal Hook
The true power of composable hooks emerges when they are combined to build more complex, domain-specific logic. Let's demonstrate this by building a useModal hook that leverages our useToggle hook. This pattern allows us to manage modal visibility without duplicating the basic boolean toggle logic, leading to cleaner, more readable, and highly maintainable code.
// hooks/useModal.ts
import { useToggle } from './useToggle'; // Assuming useToggle.ts is in the same directory
/**
* @interface UseModalResult
* @description Defines the return type for the useModal hook.
* @property {boolean} isOpen - Indicates if the modal is currently open.
* @property {() => void} openModal - Function to open the modal.
* @property {() => void} closeModal - Function to close the modal.
* @property {() => void} toggleModal - Function to toggle the modal's open state.
*/
interface UseModalResult {
isOpen: boolean;
openModal: () => void;
closeModal: () => void;
toggleModal: () => void;
}
/**
* @function useModal
* @description A custom hook for managing modal visibility, built on top of useToggle.
* @param {boolean} [initialState=false] - The initial state of the modal (open or closed).
* @returns {UseModalResult} An object containing the modal's state and control functions.
*/
export function useModal(initialState: boolean = false): UseModalResult {
const { isOpen, toggle, setTrue, setFalse } = useToggle(initialState);
return {
isOpen,
openModal: setTrue, // Alias setTrue to openModal for better semantic clarity
closeModal: setFalse, // Alias setFalse to closeModal
toggleModal: toggle, // Alias toggle to toggleModal
};
}
Here, useModal doesn't manage its own useState. Instead, it delegates the entire boolean state management to useToggle. This creates a clean separation of concerns: useToggle handles generic boolean state, while useModal provides a more semantically meaningful interface for modal-specific interactions. This layering is a fundamental principle of composable architectures.
3. Real-world Application: A Product Management Component
Now, let's see how these composable hooks integrate into a Next.js 15 application. We'll create a ProductList component that displays a list of products and allows adding new ones via a modal, all managed efficiently with our custom hooks.
// components/ProductList.tsx
import React from 'react';
import { useModal } from '../hooks/useModal'; // Adjust path as necessary
/**
* @interface Product
* @description Defines the structure for a product item.
*/
interface Product {
id: string;
name: string;
price: number;
}
/**
* @interface AddProductModalProps
* @description Props for the AddProductModal component.
*/
interface AddProductModalProps {
isOpen: boolean;
onClose: () => void;
onAddProduct: (productName: string, price: number) => void;
}
/**
* @component AddProductModal
* @description A simple modal component for adding new products.
*/
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onAddProduct }) => {
const [productName, setProductName] = React.useState<string>('');
const [productPrice, setProductPrice] = React.useState<number>(0);
const handleSubmit = () => {
if (productName.trim() && productPrice > 0) {
onAddProduct(productName, productPrice);
setProductName(''); // Clear form after submission
setProductPrice(0);
onClose();
} else {
alert('Please enter a valid product name and price.');
}
};
if (!isOpen) return null; // Render nothing if the modal is not open
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.5)', display: 'flex',
justifyContent: 'center', alignItems: 'center', zIndex: 1000
}}>
<div style={{ background: 'white', padding: '25px', borderRadius: '8px', boxShadow: '0 4px 15px rgba(0,0,0,0.2)', minWidth: '300px' }}>
<h3 style={{ marginTop: 0, marginBottom: '20px', textAlign: 'center' }}>Add New Product</h3>
<input
type="text"
placeholder="Product Name"
value={productName}
onChange={(e) => setProductName(e.target.value)}
style={{ display: 'block', marginBottom: '15px', padding: '10px', width: '100%', border: '1px solid #ccc', borderRadius: '4px' }}
/>
<input
type="number"
placeholder="Price"
value={productPrice}
onChange={(e) => setProductPrice(parseFloat(e.target.value))}
min="0"
step="0.01"
style={{ display: 'block', marginBottom: '20px', padding: '10px', width: '100%', border: '1px solid #ccc', borderRadius: '4px' }}
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px' }}>
<button onClick={onClose} style={{ padding: '10px 20px', backgroundColor: '#6c757d', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>Cancel</button>
<button onClick={handleSubmit} style={{ padding: '10px 20px', backgroundColor: '#007bff', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer' }}>Add Product</button>
</div>
</div>
</div>
);
};
/**
* @component ProductList
* @description A functional component displaying a list of products and providing an interface to add new ones.
*/
const ProductList: React.FC = () => {
// Use the composed useModal hook to manage modal state
const { isOpen, openModal, closeModal } = useModal();
// Local state for product list, managed by useState directly
const [products, setProducts] = React.useState<Product[]>([
{ id: '1', name: 'Wireless Mouse', price: 29.99 },
{ id: '2', name: 'Mechanical Keyboard', price: 79.99 },
]);
const handleAddProduct = React.useCallback((name: string, price: number) => {
const newProduct: Product = {
id: String(products.length + 1 + Math.random()), // More robust ID generation for demo
name,
price,
};
setProducts(prevProducts => [...prevProducts, newProduct]);
}, [products.length]); // Recreate if products.length changes for ID uniqueness logic
return (
<div style={{ fontFamily: 'Inter, Arial, sans-serif', maxWidth: '800px', margin: '40px auto', padding: '30px', border: '1px solid #e0e0e0', borderRadius: '10px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)' }}>
<h1 style={{ textAlign: 'center', color: '#333', marginBottom: '30px' }}>Product Inventory</h1>
<div style={{ marginBottom: '30px', textAlign: 'right' }}>
<button onClick={openModal} style={{ padding: '12px 25px', backgroundColor: '#28a745', color: 'white', border: 'none', borderRadius: '5px', cursor: 'pointer', fontSize: '16px', fontWeight: 'bold' }}>
+ Add New Product
</button>
</div>
{products.length === 0 ? (
<p style={{ textAlign: 'center', color: '#666' }}>No products available. Click "Add New Product" to get started!</p>
) : (
<ul style={{ listStyle: 'none', padding: 0, borderTop: '1px solid #eee' }}>
{products.map(product => (
<li key={product.id} style={{ borderBottom: '1px solid #eee', padding: '15px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ fontSize: '1.1em', color: '#555' }}>{product.name}</span>
<strong style={{ fontSize: '1.1em', color: '#333' }}>${product.price.toFixed(2)}</strong>
</li>
))}
</ul>
)}
{/* The modal component, controlled by useModal */}
<AddProductModal
isOpen={isOpen}
onClose={closeModal}
onAddProduct={handleAddProduct}
/>
</div>
);
};
export default ProductList;
The ProductList component effectively demonstrates the benefits of this architecture:
- Clarity: The component's logic is focused on rendering and coordinating data, not on the intricate details of modal state management.
const { isOpen, openModal, closeModal } = useModal();is highly readable. - Reusability:
useToggleanduseModalcan be effortlessly reused across different parts of the application, ensuring consistency and reducing boilerplate. - Maintainability: Changes to modal logic only affect
useModal(oruseToggle), not every component that uses a modal. - Testability: Each custom hook can be tested in isolation, simplifying the testing process for complex UIs.
As we advance towards Next.js 15 in 2026, embracing composable React Hooks with TypeScript will be increasingly vital for building robust and scalable front-end applications. By leveraging foundational hooks like useState to create specialized, reusable custom hooks, developers can achieve a cleaner separation of concerns, improve code readability, and significantly enhance the maintainability of their projects. This pattern fosters an ecosystem where complex UI behaviors are elegantly composed from smaller, well-defined, and testable units.
📚 More Resources
Check out related content:
Looking for beautiful UI layouts and CSS animations?
🎨 Need Design? Get Pure CSS Inspiration →
Comments
Post a Comment