Unlocking Ultimate Flexibility: The ReactJS Hook Prop Render Pattern
Ever found yourself reaching for children props to make a component customizable, but then wished your custom hook could also dictate how certain states are rendered? Or perhaps you've created a brilliant data-fetching hook, but every time you use it, you find yourself writing repetitive if (isLoading) and if (error) JSX blocks in the consuming component. It's a common struggle, and it's precisely where the "Prop Render Pattern" with React Hooks shines.
In my experience building complex applications and design systems, separating concerns effectively is paramount. We strive for hooks that encapsulate logic and state, and components that handle UI. But what happens when the logic influences the UI in a way that’s too granular for the component to decide, yet too UI-specific for the hook to directly render? That's the sweet spot for this pattern.
Why This Matters in Real Projects
Imagine you have a useUserProfile hook that fetches user data.
A typical usage might look like this:
function UserProfileCard({ userId }: { userId: string }) {
const { user, isLoading, error } = useUserProfile(userId);
if (isLoading) {
return <LoadingSpinner />;
}
if (error) {
return <ErrorMessage message={error.message} />;
}
if (!user) {
return <p>No user found.</p>;
}
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
{/* ... more user details */}
</div>
);
}
This works perfectly fine for simple cases. But what if UserProfileCard needs to display user data differently in a dashboard versus a settings page? Or if the LoadingSpinner and ErrorMessage need to be bespoke for each context? You'd end up duplicating conditional rendering logic or passing increasingly complex renderLoading and renderError props to UserProfileCard, which then passes them to… what? This is where we elevate the "render prop" concept directly into the custom hook.
Deep Dive: The Prop Render Pattern with Hooks
The core idea is simple: instead of your custom hook returning raw data and status flags, it can accept functions as props that it invokes at specific points in its lifecycle. These functions then return the React nodes to be rendered.
Let's refactor our useUserProfile hook to embrace this pattern.
First, a simplified useUserProfile hook that supports render functions:
import React, { useState, useEffect, useCallback } from 'react';
type User = {
id: string;
name: string;
email: string;
bio?: string;
};
type UseUserProfileOptions<TLoading, TError, TSuccess> = {
userId: string;
renderLoading: () => TLoading;
renderError: (error: Error) => TError;
renderSuccess: (user: User) => TSuccess;
renderNotFound?: () => React.ReactNode;
};
// Simulate an async API call
async function fetchUserById(userId: string): Promise<User | null> {
return new Promise((resolve) => {
setTimeout(() => {
if (userId === 'user-123') {
resolve({ id: 'user-123', name: 'Alice Smith', email: 'alice@example.com', bio: 'Frontend Dev' });
} else if (userId === 'user-404') {
resolve(null); // Simulate not found
} else if (userId === 'user-500') {
throw new Error('Server error!'); // Simulate API error
}
resolve(null);
}, 1000);
});
}
function useUserProfile<TLoading, TError, TSuccess>(
options: UseUserProfileOptions<TLoading, TError, TSuccess>
): TLoading | TError | TSuccess | React.ReactNode {
const { userId, renderLoading, renderError, renderSuccess, renderNotFound } = options;
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
let isMounted = true;
const loadUser = async () => {
setIsLoading(true);
setError(null);
setUser(null);
try {
const userData = await fetchUserById(userId);
if (isMounted) {
setUser(userData);
}
} catch (err) {
if (isMounted) {
setError(err as Error);
}
} finally {
if (isMounted) {
setIsLoading(false);
}
}
};
loadUser();
return () => {
isMounted = false;
};
}, [userId]); // Only re-run if userId changes
if (isLoading) {
return renderLoading();
}
if (error) {
return renderError(error);
}
if (!user && renderNotFound) {
return renderNotFound();
}
if (user) {
return renderSuccess(user);
}
// Fallback for unexpected states
return null;
}
Now, consuming this hook becomes incredibly powerful and declarative:
import React from 'react';
// Assuming useUserProfile and types are in './hooks/useUserProfile'
function DashboardUserWidget() {
const renderedContent = useUserProfile({
userId: 'user-123',
renderLoading: () => <p className="text-blue-500">Loading user profile...</p>,
renderError: (error) => <p className="text-red-500">Failed to load: {error.message}</p>,
renderSuccess: (user) => (
<div className="bg-gray-800 p-4 rounded-lg shadow-md text-white">
<h3 className="text-lg font-bold">{user.name}</h3>
<p className="text-sm">{user.email}</p>
<p className="text-xs italic mt-2">{user.bio}</p>
</div>
),
renderNotFound: () => <p className="text-yellow-500">User not found!</p>
});
return (
<section>
<h2 className="text-xl font-semibold mb-4">User Details</h2>
{renderedContent}
</section>
);
}
function SettingsPageUserPreview() {
const renderedContent = useUserProfile({
userId: 'user-123', // Or some other user ID for settings
renderLoading: () => <div className="animate-pulse bg-gray-200 h-16 w-full rounded"></div>,
renderError: (error) => (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative" role="alert">
<strong className="font-bold">Error!</strong>
<span className="block sm:inline"> {error.message}</span>
</div>
),
renderSuccess: (user) => (
<div className="border border-gray-300 p-3 rounded flex items-center space-x-4">
<img src={`https://i.pravatar.cc/40?u=${user.id}`} alt={user.name} className="w-10 h-10 rounded-full" />
<div>
<p className="font-medium">{user.name}</p>
<p className="text-sm text-gray-500">{user.email}</p>
</div>
</div>
)
});
return (
<section className="mt-8">
<h2 className="text-2xl font-bold mb-4">Your Profile Snapshot</h2>
{renderedContent}
</section>
);
}
Notice how DashboardUserWidget and SettingsPageUserPreview completely control the visual output for each state, even though they both use the same useUserProfile hook. The hook takes care of the data fetching logic, and the components provide the render functions. This is powerful!
Insights Most Tutorials Miss
- True Separation of Concerns: This pattern allows your custom hook to be purely about logic and state management, completely divorced from rendering details. The UI logic moves from the hook's return consumer to the hook's input consumer.
- Enhanced Reusability: Your
useUserProfilehook can now be used across vastly different UIs without needing modifications or prop drilling for rendering specifics. It becomes a plug-and-play data layer with rendering control. - Testability: You can more easily test the hook's core logic (loading, error handling, data transformation) independently, mocking the render functions. Similarly, you can test the consuming component by passing mock render functions to the hook.
-
Performance Considerations: When passing these render functions directly into the hook's options, especially if they are defined inline, they can cause unnecessary re-renders. Always wrap your
render*functions inuseCallbackif they depend on props or state from the consuming component, or if the hook itself doesn't memoize its options object.
// In DashboardUserWidget const renderSuccessCallback = useCallback((user: User) => ( <div className="bg-gray-800 p-4 rounded-lg shadow-md text-white"> {/* ... */} </div> ), []); // Empty dependency array if renderSuccess doesn't depend on outer scope const renderedContent = useUserProfile({ userId: 'user-123', renderSuccess: renderSuccessCallback, // ... other render functions });This is crucial for preventing the hook from re-running
useEffector re-evaluating its internal state due to a new function reference being passed on every parent re-render.
Pitfalls to Avoid
- Overuse: Not every hook needs this. If your hook always renders the same UI or the rendering logic is simple and never changes, returning
data,isLoading,erroris perfectly fine and often cleaner. This pattern introduces a bit more boilerplate. Use it when you anticipate multiple, significantly different ways the hook's output might need to be rendered. - Complexity Creep: If your
render*functions become extremely long or complex, it might be a sign that the consuming component is taking on too much responsibility. Break those render functions out into separate, smaller components. - Type Juggling: While TypeScript provides excellent support, defining types for complex render functions can sometimes be tricky. Stick to clear
(data: Type) => React.ReactNodesignatures. - Unintended Closures: If your render functions close over values that change frequently and you don't use
useCallback, you might run into stale closures or unnecessary re-renders. Be mindful of youruseCallbackdependencies.
Key Takeaways
The Prop Render Pattern, when integrated with custom React Hooks, is a potent tool for achieving maximum flexibility and clean separation of concerns. It transforms your hooks from mere data providers into intelligent orchestrators that delegate rendering decisions to the consuming component. By embracing this pattern thoughtfully and minding the performance implications of useCallback, you can build truly reusable, testable, and adaptable React applications. Add it to your mental toolkit for those moments when children just isn't quite enough, and you need that ultimate control over rendering logic at the hook's consumption point.
(End of Blog Post)
> 🚀 Read on My Blog
Top comments (0)