Crafting Clean Code - A Guide To Separation of Concerns
Crafting Clean Code - A Guide To Separation of Concerns
The concept of separation of concerns is not new, but its application in modern
frameworks like React requires thoughtful consideration and practical techniques.
This booklet explores how to effectively divide your codebase into distinct sections,
each responsible for a specific functionality, such as UI presentation, data fetching,
and business logic.
Throughout the pages that follow, you will discover a variety of tools and
methodologies designed to streamline your development process. We will delve into
custom hooks, state management solutions, React Query, and more, all with the goal
of enhancing the maintainability and scalability of your applications.
Whether you are a seasoned React developer or just beginning your journey, this
booklet provides insights and actionable advice to help you master the art of writing
clean, organized, and efficient code. Join us as we explore the best practices and
patterns that will empower you to build applications that are not only powerful but
also elegant in their design and implementation.
1
Chapters
2
Understanding Separation of Concerns
At its core, Separation of Concerns is about organizing your code so that different
parts of your application can evolve independently. This means each part, or
"concern," has a well-defined role and responsibility. By keeping these concerns
separate, you can make changes to one part without significantly impacting others,
facilitating easier updates, bug fixes, and feature additions.
Why is it important?
3
Benefits of Clean Code
Clean code is the direct outcome of effectively separating concerns. It is code that is
easy to read, understand, and maintain. Here's how SoC contributes to the benefits of
clean code:
4
Structuring Your React Application
Organizing your React application effectively is essential for maintaining clarity and
efficiency as your project grows. Proper structuring not only enhances readability
and maintainability but also facilitates collaboration among team members. This
chapter explores best practices for organizing components and introduces the
container-presenter pattern, which helps separate logic from presentation.
Component Organization
Components are the building blocks of any React application. Organizing them
thoughtfully is crucial for creating a scalable and maintainable codebase. Here are
some best practices for component organization:
1. Directory Structure:
○ Flat Structure: For small projects, a flat structure with all components in
a single directory might be sufficient. However, as your application
grows, consider organizing components into feature-based or
domain-based directories.
○ Feature-Based Structure: Group components by feature or
functionality. For example, if your app has user management and
product management features, you might have a components/user
directory and a components/product directory.
○ Domain-Based Structure: Organize components based on their role or
domain, such as ui, layout, or forms.
2. Component Naming:
○ Use clear and descriptive names for components. A component's name
should reflect its purpose or the UI element it represents.
○ Use PascalCase for component file names and class names, e.g.,
UserProfile.js and ProductList.js.
5
3. File Organization:
○ Keep each component in its own file to promote reusability and
separation of concerns.
○ For complex components, consider breaking them into smaller
sub-components, each with its own file.
4. Reusable Components:
○ Identify common UI elements and abstract them into reusable
components. This reduces duplication and ensures consistency across
the application.
○ Maintain a common or shared directory for these reusable components.
5. Styles and Assets:
○ Co-locate styles and assets with their respective components when
possible. This makes it easier to manage and understand the
component's structure.
○ Use CSS modules or styled-components to scope styles locally and
prevent conflicts.
Presenter Components:
6
Example:
Container Components:
○ Handle the business logic, including data fetching, state management, and
event handling.
○ Pass data and callback functions as props to presenter components.
○ Often connected to global state or perform side-effects like API calls.
Example:
function UserProfileContainer() {
const [user, setUser] = useState(null);
useEffect(() => {
fetch('/api/user')
.then(response => response.json())
.then(data => setUser(data));
}, []);
7
Benefits of the Container-Presenter Pattern:
function withLogging(Component) {
return function WrappedComponent(props) {
useEffect(() => {
console.log('Component props:', props);
}, [props]);
8
HOCs are particularly effective for separating logic from the presentation layer. By
using HOCs, you can encapsulate complex logic and manage state separately from
the component that handles rendering.
Authentication HOC
function withAuth(Component) {
return function WrappedComponent(props) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
useEffect(() => {
const checkAuth = () => {
const user = localStorage.getItem('user');
setIsAuthenticated(!!user);
};
checkAuth();
}, []);
9
Data Fetching HOC
Similarly, a Data Fetching HOC can handle the logic of fetching data and manage
loading and error states.
useEffect(() => {
fetch(url)
.then(response => response.json())
.then(data => {
setData(data);
setLoading(false);
})
.catch(error => {
setError(error);
setLoading(false);
});
}, [url]);
return (
<Component
data={data}
loading={loading}
error={error}
{...props}
/>
);
};
}
1. Naming Conventions: Name your HOCs clearly to indicate their purpose. For
example, withAuth for authentication and withDataFetching for data
retrieval.
10
2. Prop Management: Ensure that HOCs correctly forward props to the wrapped
component. This maintains the integrity of the wrapped component’s
interface.
3. Composition: HOCs can be combined to compose multiple functionalities. For
instance, you can use an authentication HOC and a data fetching HOC
together.
11
Managing State Effectively
Redux
Core Concepts:
Example:
// actions.js
export const increment = () => ({
type: 'INCREMENT'
});
12
// reducer.js
const counterReducer = (state = 0, action) => {
switch (action.type) {
case 'INCREMENT':
return state + 1;
default:
return state;
}
};
// store.js
import { createStore } from 'redux';
import counterReducer from './reducer';
// component.js
import React from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { increment } from './actions';
function Counter() {
const count = useSelector(state => state);
const dispatch = useDispatch();
return (
<div>
<p>{count}</p>
<button onClick={() => dispatch(increment())}>Increment</button>
</div>
);
}
13
MobX
Overview: MobX is a library that uses observable state and reactive programming
principles. It simplifies state management by allowing components to automatically
react to state changes.
Core Concepts:
Example:
// store.js
import { makeAutoObservable } from 'mobx';
class CounterStore {
count = 0;
constructor() {
makeAutoObservable(this);
}
increment() {
this.count++;
}
}
const counterStore = new CounterStore();
export default counterStore;
// component.js
import React from 'react';
import { observer } from 'mobx-react-lite';
import counterStore from './store';
14
</div>
));
Zustand
Overview: Zustand is a small, fast state management library with a simple API. It
provides a store that can be accessed and updated directly.
Core Concepts:
Example:
// store.js
import create from 'zustand';
// component.js
import React from 'react';
import useStore from './store';
function Counter() {
const { count, increment } = useStore();
return (
<div>
<p>{count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
15
Other Libraries
Recoil: A state management library for React that provides a more flexible approach
to managing state with atoms and selectors.
XState: Manages state using finite state machines, which is useful for complex state
transitions and workflows.
The Context API is a built-in feature of React that allows you to share state between
components without having to pass props down manually through every level of the
component tree.
○ Global State: When you need to share state across many components.
○ Theming: To manage theme settings or other global configuration.
○ Authentication: To provide user authentication status throughout your app.
// context.js
import React from 'react';
16
2. Provide Context: Use Context.Provider to make the context value available
to child components.
// ThemeProvider.js
import React, { useState } from 'react';
import ThemeContext from './context';
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// ThemedComponent.js
import React, { useContext } from 'react';
import ThemeContext from './context';
function ThemedComponent() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<div style={{ background: theme === 'light' ? '#fff' : '#333', color:
theme === 'light' ? '#000' : '#fff' }}>
<p>The current theme is {theme}</p>
<button onClick={toggleTheme}>Toggle Theme</button>
</div>
17
);
}
○ Avoid Overuse: Use Context for global state but avoid it for local component
state to prevent unnecessary re-renders.
○ Split Contexts: Create multiple contexts if you have different types of state
(e.g., theme, authentication) to keep contexts focused and manageable.
○ Performance Considerations: Use memoization and avoid large context
values to optimize performance.
18
Leveraging Custom Hooks
Custom Hooks are a powerful feature in React that allow you to encapsulate and
reuse stateful logic across multiple components. By extracting logic into reusable
hooks, you can keep your components clean and focused on rendering, while
managing complex state and effects in a modular way.
A Custom Hook is a JavaScript function whose name starts with "use" and that can
call other hooks. Custom Hooks allow you to extract component logic into reusable
functions, which can then be shared across different components.
1. Define the Hook: Create a function that uses built-in hooks (like useState,
useEffect, etc.) and returns values or functions.
2. Use the Hook: Call the custom hook within a functional component to leverage
its functionality.
A common use case for custom hooks is managing form state and handling form
submissions. Here’s an example of a custom hook that simplifies form handling.
// useForm.js
import { useState } from 'react';
function useForm(initialValues) {
const [values, setValues] = useState(initialValues);
19
}));
};
return {
values,
handleChange,
resetForm
};
}
Usage in a Component:
// MyForm.js
import React from 'react';
import useForm from './useForm';
function MyForm() {
const { values, handleChange, resetForm } = useForm({ name: '', email: ''
});
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input
type="text"
name="name"
value={values.name}
onChange={handleChange}
/>
20
</label>
<label>
Email:
<input
type="email"
name="email"
value={values.email}
onChange={handleChange}
/>
</label>
<button type="submit">Submit</button>
</form>
);
}
21
Implementing a Service Layer
A service layer is a pattern that separates the logic for interacting with external
services or APIs from the rest of your application. It acts as an intermediary between
your application's components and the backend, allowing you to encapsulate all the
details of data fetching, transformation, and error handling.
Consider a simple service layer for managing API interactions related to user data.
// apiService.js
const BASE_URL = 'https://api.example.com';
22
export async function getUser(userId) {
return fetchFromAPI(`users/${userId}`);
}
Usage in a Component:
// UserComponent.js
import React, { useEffect, useState } from 'react';
import { getUser } from './apiService';
useEffect(() => {
const fetchUser = async () => {
try {
const data = await getUser(userId);
setUser(data);
} catch (error) {
setError(error.message);
}
};
fetchUser();
}, [userId]);
23
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
When dealing with API interactions, it's crucial to handle errors gracefully and
implement strategies for retrying failed requests. Robust error management ensures
that your application remains reliable and provides a better user experience.
24
if (error) return <p>Something went wrong: {error.message}</p>;
Sometimes, network requests fail due to transient issues (e.g., network instability).
Implementing retry logic can improve the reliability of your application.
// apiService.js
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000; // milliseconds
○ Centralize API Logic: Keep all API interactions in a dedicated service layer to
avoid scattering logic across components.
○ Handle Different Error Types: Distinguish between different error types (e.g.,
network errors, API errors) and provide specific handling for each.
○ Implement Retry Logic Thoughtfully: Use retry logic judiciously to avoid
overwhelming the server or introducing unnecessary delays.
25
Using React Query for Data Fetching
React Query is a powerful library that simplifies data fetching, caching, and
synchronization in React applications. It provides a robust set of tools for managing
server state and makes working with data fetching and synchronization
straightforward. This chapter will guide you through using React Query to handle
data fetching efficiently in your React applications.
To start using React Query, you need to install the library and set up a QueryClient
and QueryClientProvider in your application.Wrap your application with
QueryClientProvider to provide the query client to the entire app.
// index.js
import React from 'react';
import ReactDOM from 'react-dom';
26
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
ReactDOM.render(
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>,
document.getElementById('root')
);
React Query provides hooks like useQuery and useMutation to handle data fetching
and mutations.
The useQuery hook is used for fetching data from an API. It takes a unique query key
and a function that returns a promise.
// api.js
export async function fetchUser(userId) {
const response = await fetch(`https://api.example.com/users/${userId}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
}
// UserComponent.js
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { fetchUser } from './api';
27
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<div>
<h1>{data.name}</h1>
<p>{data.email}</p>
</div>
);
}
The useMutation hook is used for creating, updating, or deleting data. It manages
the state of mutations and provides utilities for handling success and error states.
// api.js
export async function createUser(userData) {
const response = await fetch('https://api.example.com/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(userData),
});
if (!response.ok) {
throw new Error('Network response was not ok');
28
}
return response.json();
}
// CreateUserForm.js
import React, { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { createUser } from './api';
function CreateUserForm() {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
return (
<form onSubmit={handleSubmit}>
<label>
Name:
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</label>
<label>
Email:
<input
type="email"
value={email}
29
onChange={(e) => setEmail(e.target.value)}
/>
</label>
<button type="submit" disabled={mutation.isLoading}>
{mutation.isLoading ? 'Creating...' : 'Create User'}
</button>
{mutation.error && <p>Error: {mutation.error.message}</p>}
{mutation.isSuccess && <p>User created successfully!</p>}
</form>
);
}
○ Use Query Keys Wisely: Use unique and descriptive query keys to identify
different queries and avoid collisions.
○ Optimize Cache Time: Configure staleTime and cacheTime based on how
frequently data changes and your application’s needs.
○ Leverage DevTools: Use React Query DevTools to monitor queries, manage
cache, and debug issues.
30
Future-Proofing Your Code
Scalability Considerations
1. Modular Architecture
Efficient data management is crucial for scalability. Consider the following practices:
3. Performance Optimization
31
Optimize your application’s performance to handle higher loads and ensure smooth
user experiences.
○ Lazy Loading: Load components or data only when needed to reduce initial
load times.
○ Code Splitting: Use tools like Webpack or React’s React.lazy to split your
code into smaller bundles.
Adapting to Change
Design patterns offer proven solutions to common design problems and make your
codebase more flexible.
○ Factory Pattern: Allows for the creation of objects without specifying the exact
class of object that will be created.
○ Observer Pattern: Enables a subject to notify observers about changes
without being tightly coupled.
Write clean, readable code to make future changes and maintenance easier.
Automated testing helps ensure that your code remains functional as changes are
made.
32
○ Integration Testing: Test how different components or systems work together.
○ End-to-End Testing: Test the entire application flow from start to finish.
Feature flags enable you to toggle features on or off without deploying new code.
○ Gradual Rollouts: Roll out new features gradually to test their impact.
○ A/B Testing: Test different versions of a feature to see which performs better.
33