Tách Logic trong React: Presentational Components và Hooks
Vấn đề
Có những component quá lớn, chứa nhiều logic phức tạp khiến file dài và khó đọc.
Giải pháp
Ý tưởng cốt lõi rất đơn giản: tách các concern. Tách phần “hiển thị như thế nào” khỏi phần “nó làm gì”.
Trong bài này, chúng ta sẽ tìm hiểu hai cách phổ biến để thực hiện việc này:
- Solution A: Dùng pattern Presentational component + Container component.
- Solution B: Dùng cách tiếp cận với “hooks”.
So sánh các giải pháp
Lưu ý
- Theo quan điểm cá nhân, mỗi cách đều có ưu và nhược điểm; không có cách nào tốt hơn 100%. Vì vậy, cần cân nhắc sự cân bằng.
- Các implementation sẽ được viết đơn giản nhất có thể, nên một số chỗ có thể chưa theo best practices!
Use Case
Một nền tảng blog cho phép người dùng tạo bài viết. Người khác có thể xem, comment hoặc react bằng message và image (gọi là Blog Posts).
Giả sử Blog Post component (gọi là Post) rất lớn và phức tạp, nên cần được tách nhỏ.
Implementation của cả hai giải pháp
Folder structure được đơn giản hóa để tập trung vào các giải pháp.
Solution A: Presentation + Container Component
- Presentational Components: Đây là các UI component thuần. Chúng nhận data và callback qua props. Có thể xem chúng như phần phụ trách việc hiển thị của application.
- Container Components: Đây là phần xử lý chính. Chúng phụ trách data fetching, state management và business logic, sau đó truyền data và handler cần thiết xuống presentational children qua props.
// src/components/Post/PostPresentational.jsx
export function PostPresentational(props) {
// Chỉ chứa logic liên quan đến việc hiển thị và UI
return '...'; // Render UI của Post dựa trên props
}
// src/components/Post/PostContainer.jsx
import { PostPresentational } from './PostPresentational';
export default function PostContainer(props) {
/**
* Chứa logic phức tạp dựa trên `props`, chẳng hạn:
* - function: Fetch post
* - function: Delete post (Optimistic) và hiển thị toast message
* - function: Edit post (Optimistic) và hiển thị toast message
* - internal state để xử lý trạng thái loading/disable khi edit/delete
* (giả sử có context nơi edit/delete xảy ra; một số page cần
* hiển thị state, một số không cần vì dùng progress bar -
* "GIẢ ĐỊNH NÀY ĐƯỢC THÊM ĐỂ TĂNG ĐỘ PHỨC TẠP")
*/
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 = () => {
// logic redirect
};
return <Post {...props} onDeleteSuccess={redirectAfterDelete} />;
}
Solution B: Hook
Hooks là một cách mạnh để tách stateful logic khỏi functional component. Thay vì tạo một container component riêng, chúng ta có thể đóng gói logic phức tạp trong một custom hook.
// src/components/Post/usePost.jsx
export function usePost(props) {
/**
* Chứa logic phức tạp dựa trên `props`, chẳng hạn:
* - function: Fetch post
* - function: Delete post (Optimistic) và hiển thị toast message
* - function: Edit post (Optimistic) và hiển thị toast message
* - internal state để xử lý trạng thái loading/disable khi edit/delete
* (giả sử có context nơi edit/delete xảy ra; một số page cần
* hiển thị state, một số không cần vì dùng progress bar -
* "ASSUMPTION ĐƯỢC THÊM ĐỂ TĂNG ĐỘ PHỨC TẠP")
*/
return {
// Mọi thứ mà Post component cần sử dụng
};
}
// src/components/Post/Post.jsx
import { usePost } from './usePost';
export default function Post(props) {
/**
* Thay vì PostPresentational như trong Solution A,
* ở đây developer đưa "complex logic" vào Post
* => loại bỏ Presentational layer
*/
const internal = usePost(props);
return '...'; // Render UI của Post, sử dụng các giá trị từ '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) {
// Tương tự Solution A
const redirectAfterDelete = () => {
// logic redirect
};
return <Post {...props} onDeleteSuccess={redirectAfterDelete} />;
}
Đánh giá ban đầu
Solution A
- Tách Logic và UI rõ ràng.
- Nếu chỉ cần UI, bạn có thể import trực tiếp PostPresentational.
Solution B
- Tách logic vào một hook riêng và dùng trực tiếp trong Post component.
- Dù đã tách logic, rendering logic và việc sử dụng hook vẫn nằm trong cùng component. Theo nghĩa chặt chẽ, đây chưa phải separation of concerns hoàn toàn, nhưng tổ chức code được cải thiện đáng kể.
Refactor cho Use Case phức tạp hơn
Tiếp tục với use case trên, giả sử chúng ta cần “reuse” phần LOGIC của Blog Post!
Refactor Solution A
// Rename + Refactor: src/components/Post/PostContainer.jsx => src/components/Post/PostLogic.jsx
import { PostPresentational } from './PostPresentational';
export function PostLogic({ Component, ...props }) {
// Toàn bộ Logic từ PostContainer ở section phía trên
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) {
// Tương tự Solution A
const redirectAfterDelete = () => {
// logic redirect
};
return <PostLogic {...props} onDeleteSuccess={redirectAfterDelete} Component={PostWithCommentList} />;
}
Refactor Solution B
// Rename src/components/Post/Post.jsx => src/components/Post/PostPresentational
export function PostPresentational(props) {
// Chỉnh sửa để giống PostPresentational trong 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) {
// Tương tự Solution A
const redirectAfterDelete = () => {
// logic redirect
};
return <PostWithCommentList {...props} onDeleteSuccess={redirectAfterDelete} />;
}
Đánh giá cuối cùng (mang tính chủ quan)
- Có thể sử dụng hai cách thay thế cho nhau khi cần.
- Với solution A, đôi khi code khó hiểu hơn (passedPropsFromPostLogic yêu cầu bạn nhớ rằng props đã được PostLogic thay đổi và có thể không còn giống
propstừ DetailPage).
Lưu ý: 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.”
Chọn cách tiếp cận
Cuối cùng, cách “tốt nhất” thường phụ thuộc vào context. Theo kinh nghiệm cá nhân, và có hơi thiên về sự rõ ràng, tôi thường đi theo các bước sau khi gặp một component quá phức tạp:
-
Identify and Extract Utilities: Tìm các pure function hoặc logic không phụ thuộc trực tiếp vào state hay lifecycle của component. Đưa chúng sang các utility file.
-
Dùng Custom Hooks: Với stateful logic cần reuse hoặc chỉ đơn giản là muốn tách ra để code gọn hơn, custom hooks là lựa chọn rất phù hợp.
-
Cân nhắc Facade Hook: Nếu một component sử dụng quá nhiều hooks, hãy cân nhắc tạo một “facade” hook để đóng gói chúng và cung cấp interface gọn hơn cho component.
-
Presentational/Container là lựa chọn cuối: Nếu component vẫn quá phức tạp sau khi tách utilities và hooks, hãy cân nhắc chia thành Presentational + Container/Logic components.
Hãy nhớ: Đừng để React components trở nên quá lớn và khó quản lý. Hiểu và áp dụng đúng các pattern như Presentational/Container, kết hợp với sức mạnh của Hooks, sẽ giúp bạn tạo ra những unit code nhỏ, tập trung hơn và dễ hiểu, test, maintain hơn. Hãy chọn cách phù hợp với nhu cầu và convention của team; đôi khi kết hợp cả hai lại là giải pháp tốt nhất.