Decoupling Logic in React: Presentational Components and Hooks
Problem Statement
There are really large components, containing complex logic that makes the files really long and difficult to read.
Solutions
The core idea is simple: separate the concerns. Decouple the “what it looks like” from the “what it does”.
In this exploration, we’ll dive into two popular strategies for achieving this separation:
- Solution A: Follow the Presentational component + Container component pattern.
- Solution B: Follow the approach using “hooks”.
Solution Comparison
Disclaimer
- In my personal opinion, each method has its advantages and disadvantages; neither is 100% superior. Therefore, a balance needs to be considered.
- The implementations will be written in the simplest way, so some places might not follow best practices!
Use Case
A blog platform allows users to create posts. Other users view these posts and can comment or react by leaving messages and images (called Blog Posts).
The assumption here is that the Blog Post component (referred to as Post) is very large and complex, so it needs to be broken down.
Implementation of Both Solutions
The folder structure has been simplified to better focus on the solutions.
Solution A: Presentation + Container Component
- Presentational Components: These are your pure UI components. They receive data and callbacks as props. Think of them as the visual artists of your application.
- Container Components: These are the brains of the operation. They handle the data fetching, state management, and business logic. They then pass down the necessary data and handlers as props to their presentational children. They are the strategists, orchestrating the data flow.
// src/components/Post/PostPresentational.jsx
export function PostPresentational(props) {
// Contains only display and UI-related logic
return '...'; // Render the Post UI based on props
}
// src/components/Post/PostContainer.jsx
import { PostPresentational } from './PostPresentational';
export default function PostContainer(props) {
/**
* Contains complex logic based on `props` such as:
* - function: Fetch post
* - function: Delete post (Optimistic) and show toast message
* - function: Edit post (Optimistic) and show toast message
* - internal state to handle loading/disable state when editing/deleting
* (assuming there are contexts where edit/delete occurs; some pages need
* to display state, some don't because they use progress bars -
* "ASSUMPTION ADDED TO INCREASE COMPLEXITY")
*/
return <PostPresentational {...props} />;
}
// src/components/Post/index.js
export { default as Post } from './PostContainer';
export * from './PostPresentational';
// src/components/MyPage.jsx
import { Post } from './Post';
export function MyPage(props) {
const redirectAfterDelete = () => {
// redirect logics
};
return <Post {...props} onDeleteSuccess={redirectAfterDelete} />;
}
Solution B: Hook
Provide a powerful way to extract stateful logic from functional components. Instead of creating a separate container component, we can encapsulate the complex logic within a custom hook.
// src/components/Post/usePost.jsx
export function usePost(props) {
/**
* Contains complex logic based on `props` such as:
* - function: Fetch post
* - function: Delete post (Optimistic) and show toast message
* - function: Edit post (Optimistic) and show toast message
* - internal state to handle loading/disable state when editing/deleting
* (assuming there are contexts where edit/delete occurs; some pages need
* to display state, some don't because they use progress bars -
* "ASSUMPTION ADDED TO INCREASE COMPLEXITY")
*/
return {
// Everything needed for the Post component to use
};
}
// src/components/Post/Post.jsx
import { usePost } from './usePost';
export default function Post(props) {
/**
* Instead of PostPresentational from Solution A,
* here the developer includes "complex logic" in Post
* => eliminating the Presentational layer
*/
const internal = usePost(props);
return '...'; // Render the Post UI, utilizing values from 'internal'
}
// src/components/Post/index.js
export { default as Post } from './Post';
export * from './usePost';
// src/components/HomePage.jsx
import { Post } from './Post';
export function HomePage(props) {
// Similar to Solution A
const redirectAfterDelete = () => {
// redirect logics
};
return <Post {...props} onDeleteSuccess={redirectAfterDelete} />;
}
Preliminary Evaluation of Both Solutions
Solution A
- Clearly separates Logic and UI.
- If you only need to use the UI, you can directly import PostPresentational.
Solution B
- Separates logic into a dedicated hook and use it in the Post component directly.
- While it separates the logic, the rendering logic and the hook usage still reside within the same component. This might not be a complete separation of concerns in the strictest sense, although it significantly improves organization.
Refactoring for More Complex Use Cases
Continuing with the above use case, assume there’s a need to “reuse” the LOGIC part of the Blog Post!
Refactoring Solution A
// Rename + Refactor: src/components/Post/PostContainer.jsx => src/components/Post/PostLogic.jsx
import { PostPresentational } from './PostPresentational';
export function PostLogic({ Component, ...props }) {
// All Logic from PostContainer in section above
return <Component {...props} />;
}
// src/components/Post/PostContainer.jsx (Refactored)
import { PostLogic } from './PostLogic';
import { PostPresentational } from './PostPresentational';
export default function PostContainer(props) {
return <PostLogic {...props} Component={PostPresentational} />;
}
// src/components/Post/index.jsx
export { default as Post } from './PostContainer';
export * from './PostPresentational';
export * from './PostLogic';
// src/components/DetailPage.jsx (New Page)
import { Post, PostLogic } from './Post';
function PostWithCommentList(passedPropsFromPostLogic) {
const [comments, setComments] = useState([]);
useEffect(() => {
const { post } = passedPropsFromPostLogic;
api.fetchComment(post.id).then(setComments);
}, [passedPropsFromPostLogic]);
return (
<React.Fragment>
<Post {...passedPropsFromPostLogic} />
<PostComments comments={comments} />
</React.Fragment>
);
}
export function DetailPage(props) {
// Similar to Solution A
const redirectAfterDelete = () => {
// redirect logics
};
return <PostLogic {...props} onDeleteSuccess={redirectAfterDelete} Component={PostWithCommentList} />;
}
Refactoring Solution B
// Rename src/components/Post/Post.jsx => src/components/Post/PostPresentational
export function PostPresentational(props) {
// Modified to be identical to PostPresentational in Solution A
return '...';
}
// src/components/Post/PostContainer.jsx (New)
import { usePost } from './usePost';
export default function PostContainer(props) {
const internalProps = usePost(props);
return <PostPresentational {...internalProps} />;
}
// src/components/Post/index.js (Refactor)
export { default as Post } from './PostContainer';
export * from './usePost';
export * from './PostPresentational';
// src/components/DetailPage.jsx (New Page)
import { usePost, PostPresentational } from './Post';
function PostWithCommentList(props) {
const postProps = usePost(props);
const [comments, setComments] = useState([]);
useEffect(() => {
const { post } = postProps;
api.fetchComment(post.id).then(setComments);
}, [postProps]);
return (
<React.Fragment>
<PostPresentational {...postProps} />
<PostComments comments={comments} />
</React.Fragment>
);
}
export function DetailPage(props) {
// Similar to Solution A
const redirectAfterDelete = () => {
// redirect logics
};
return <PostWithCommentList {...props} onDeleteSuccess={redirectAfterDelete} />;
}
Final Evaluation of Both Solutions (subjective)
- They can be used interchangeably if necessary.
- If following solution A, it can sometimes be harder to understand (passedPropsFromPostLogic requires remembering that props have been modified by PostLogic and may no longer match the
propspassed in from DetailPage).
Disclaimer: From the beginning, both solutions could have been done well to avoid refactoring/rework… However, the author deliberately wrote in a way that’s “slightly biased.”
Choosing Your Weapon
Ultimately, the “best” approach is often context-dependent. In my experience, and perhaps with a slight personal bias towards explicitness, I often find myself following these steps when faced with an overly complex component:
-
Identify and Extract Utilities: Look for pure functions or logic that isn’t inherently tied to the component’s state or lifecycle. Move these to utility files.
-
Embrace Custom Hooks: For stateful logic that needs to be reused or simply extracted for better organization, custom hooks are your best friend.
-
Consider a Facade Hook: If a component ends up using a multitude of hooks, consider creating a single “facade” hook that encapsulates them, providing a cleaner interface for the component.
-
The Presentational/Container as a Last Resort: If the component remains unwieldy even after extracting utilities and hooks, check if you need to break it down into Presentational + Container/Logic components.
Remember: Don’t let your React components balloon into unmanageable behemoths. By understanding and strategically applying patterns like Presentational/Container components and leveraging the power of Hooks, you can carve out smaller, more focused units of code that are easier to understand, test, and maintain. Choose the approach that best suits your specific needs and team conventions, and remember that sometimes, a thoughtful combination of both might be the most elegant solution.