Introduction
As my interest in writing better code for my recent project grew, I naturally learned about Separation of Concerns, one of the design principles of software development. As a result, I want to share my thoughts and briefly introduce how I applied the principle of Separation of Concerns in my React project.
Before discussing the Separation of Concerns in React, it is essential to understand what Separation of Concerns means.
Separation of Concerns
As shown in the picture, you can see that the food in the refrigerator is separated according to its type.
This makes it easy to find food at any time and also makes it easy to organize and store food later.
So, what is the Separation of Concerns in programming?
Separation of Concerns can be defined as a software design principle that recommends separating various concerns or aspects of a program into separate module units.
In simple terms, the Separation of Concerns is the most fundamental principle for writing good code.
Many design patterns, techniques, architectures, and more, for creating better applications, ultimately rely on SoC as their fundamental principle.
- Divide code units to focus on one concern at a time.
- Ensure that each code unit operates only on its relevant concern.
By separating concerns, a module has only one concern. If it only has one concern, it means that there is only one reason for this code to be modified.
Applying Separation of Concerns in a Project
While refactoring an existing project, I discovered that all concerns were mixed up in a component, with over 500 lines of code. Despite being the one who wrote the code, I couldn’t figure out where to start fixing it.
After completing the project and a few months passing, I instinctively knew that the code was not clean or good. It was difficult to understand the purpose of the component, and even understanding the logic was challenging. The code was difficult to read.
I wrote the code with the mindset of finishing the project quickly to save time, thinking that I would fix it later.
While refactoring the code, I realized that I needed to write good and clean code, and I decided to apply the principle of Separation of Concerns to the project.
So, how can I apply Separation of Concerns in React?
Separating Concerns in a Project
How do we separate View logic from Business logic?
View
- The area where the user interacts with the application on the screen
- The area where data is displayed on the screen
- Examples:
Image
,Form
,Button
, etc. - Examples: pure functional components that depend only on the
props
injected from outside
Business Logic
- Logic that communicates with the server (
api
) - Logic that manipulates the
dom
(ref
) - Logic that manages
client-state
(redux
,recoil
,useState
) - Event handlers (for
Button
,Input
, etc.)
I divided them based on these criteria and applied them to the code.
Original code
// API call logic const getNotifications = async (pageParam) => { const res = await api.patch(`/notifications?page=${pageParam}&size=10`); ... }; // Using useInfiniteQuery to fetch data with React Query const { isSuccess, data, fetchNextPage, hasNextPage } = useInfiniteQuery( ... ); // Logic for fetching more notifications when scrolling to the bottom const handleIntersect = useCallback( ... if (entry.isIntersecting && hasNextPage) { fetchNextPage(); } }, ... ); useEffect(() => { // Attach intersection observer when component mounts }, [handleIntersect, data]); // Logic for deleting notifications with a mutation const queryClient = new useQueryClient(); const handleAllDeleteMutation = async () => { ... }; const mutation = new useMutation(handleAllDeleteMutation, { ... }); const handleAllDelete = () => { ... }; // Logic for fetching notifications when the user logs in useEffect(() => { if (getCookies !== undefined) { api.get(`/notifications`) } }, [getCookies]); // Logic for updating notifications in real time with a socket const socketRef = useRef(null); const stompClientRef = useRef(null); const subscriptionRef = useRef(null); // State to keep track of whether there are new notifications const [notifications, setNotifications] = useState({ isBadge: true }); useEffect(() => { if (userId) { socketRef.current = new SockJS(socketServerURL); stompClientRef.current = Stomp.over(socketRef.current); ... }; } }, [userId]); // View return ( <Container> <NotificationWrapper> ... )}
This is a component that displays real-time notifications in my project. The original code had no separation between View and Business logic and was almost 500 lines long.
All concerns were in one component, so when I needed to modify something, I had to modify all related logic. It was difficult to modify and extend the code.
Separation of concerns through Custom Hook
When reading the explanation on Custom Hooks in the React Docs, mentions that Hooks can be created for specific purposes, such as fetching data, tracking whether a user is online, or entering a chat room.
Based on these purposes, business logic can be abstracted and created as Custom Hooks.
Logic for communicating with the server
This logic can be created using a server-state
library such as React Query or as a Custom Hook.
const useGetNotifications = () => { const { isSuccess, data: notifications, fetchNextPage, hasNextPage } = useInfiniteQuery( [QueryKeys.notifications], ({ pageParam = 0 }) => fetchNotifications(pageParam), { getNextPageParam: (lastPage) => !lastPage?.last ? lastPage?.nextPage : undefined, onError(err) { if (err instanceof ApiError) toast.error(err.message); }, } ); return { notifications, hasNextPage, fetchNextPage, isSuccess }; }; export default useGetNotifications;
This is a logic for fetching data using useInfiniteQuery
in React Query
.
const useDeleteAllnotifications = () => { const { mutate: onDelteAllNotifications } = useMutation( deleteAllNotifications, { onSuccess() { return queryClient.invalidateQueries([QueryKeys.notifications]); }, onError(err) { if (err instanceof ApiError) toast.error(err.message); } } ); return { onDelteAllNotifications }; }; export default useDeleteAllnotifications;
This is a logic for deleting notifications using Mutation
in React Query
Manipulating the DOM logic
Logic for manipulating the position of DOM
or scrolling can be created as a Custom Hook
.
const useFetchOnScroll = ({ fetchNextPage, notifications, setNotificationsBadge }: UseFetchOnScrollProps) => { const { ref, inView } = useInView(); useEffect(() => { setNotificationsBadge((prev) => { return { ...prev, isBadge: true }; }); if (inView) { fetchNextPage(); } }, [inView, notifications]); return { ref }; }; export default useFetchOnScroll;
By checking the value of inView
, this Custom Hook
uses the fetchNextPage
function returned by the useInfiniteQuery
hook to implement infinite scrolling.
Client-state
For managing state within a Custom Hook
, use React Query
Custom Hooks
for Client-state
management rather than using useState
, useSelector
, or useDispatch
, which are typically used within a component.
const { user } = useGetUser();
This code snippet uses a Custom Hook
created with React Query
to manage Client State
.
Event handler
Since event handlers are typically only used within a single component, they can be implemented within the component itself.
const handleAllDelete = () => { const res = window.confirm('Do you want to delete all notifications?'); if (res) { return onDelteAllNotifications(); } };
This event handler deletes notifications when the onClick
event is triggered.
Naming of Custom Hooks
Custom hooks are named according to the recommended naming convention in the official documentation to allow for an easy understanding of the code based solely on the abstracted name.
When naming a Custom Hook
, the official documentation recommends using a naming convention that makes it easy for someone unfamiliar with the code to understand its purpose, requirements, and return value. Here are some examples of custom hook names and their purposes:
useGetUser
: Custom hook that fetches user informationuseDeleteAllNotifications
: Custom hook that deletes all notificationsuseGetNotifications
: Custom hook that retrieves all notificationsuseStompNotifications
: Custom hook that handles notifications usingStomp
useFetchOnScroll
: Custom hook that fetches notifications on scroll
When synchronizing with external libraries, it is recommended to use more technical and specialized terminology in the name of the Custom Hook. This makes it clearer to people familiar with the library what the Hook is doing.
const { subscribe, isConnected, unsubscribe, disconnect } = useWebSocketStomp(socketServerURL);
the name useWebSocketStomp
implies that it is related to using STOMP
(Simple Text Oriented Messaging Protocol) with a WebSocket
connection.
Refactored Code
const Notifications = () => { const { user } = useUser(); const userId = user && user.userId; const { notifications, hasNextPage, fetchNextPage, isSuccess } = useGetNotifications(); const { onDelteAllNotifications } = useDeleteAllnotifications(); const { setNotificationsBadge } = useStompNotifications(userId); const { ref } = useFetchOnScroll({ notifications, fetchNextPage, setNotificationsBadge }); const handleAllDelete = () => { const res = window.confirm('전체 알림을 삭제하시겠습니까?'); if (res) { return onDelteAllNotifications(); } }; return ( <Container> <NotificationWrapper> ... <NotificationWrapper/> }
Looking at the refactored code, it is easy to understand what data the component is rendering and how it is rendering it.
Furthermore, because the concerns are separated, it is easy to quickly find the logic you are looking for.
For example, you only need to look at the relevant Custom Hook
to see the logic for fetching data, and only need to look at the relevant Custom Hook
to see the logic for handling scrolling.
In summary, the code is easier to read and understand, and logic can be easily located due to concerns being separated.
Clean code is not about short code, but about code that is easy to read.
By separating concerns, we can quickly find the logic we are looking for and easily understand the role of the component.
- A good custom hook limits the tasks it performs, making the calling code more declarative.
- It also makes it easy to understand the role of the corresponding component.
- By separating the logic into independent functions, one logic can be repeatedly reused in many places.
Things to be careful of
As mentioned in the React
official documentation, it is not a good idea to group all possible code into one custom hook.
Hide only the parts that don’t need to be understood how they work as Custom Hooks.
The important thing is not to hide all the logic, but to abstract it appropriately!
The reason for declarative programming is to separate the concerns of business logic and view so that we can quickly understand the role of the declarative component and find the logic quickly.
Reflection
During the project refactoring, I experienced how difficult it is to modify and understand dirty code. To write better and cleaner code, I learned about the concept of separation of concerns and introduced Custom Hooks.
After applying the separation of concerns, the project’s code became more concise and easier to understand. Because I did my best to write code, I can now easily refactor or migrate in the future.
From now on, I will always strive to write good and clean code.
As I continue to research and ponder what the standards and methods are for writing good code, I may eventually find myself naturally writing good code without even realizing it.
References
Reusing Logic with Custom Hooks — React
React 에서 비즈니스 로직 분리하기 (Custom Hooks Pattern)