Designing a scalable and maintainable architecture for a React application can be challenging, especially when integrating TypeScript and Redux for state management. Using TypeScript with React helps ensure type safety and reliability, while Redux provides a robust structure for managing complex application states. This article outlines best practices for designing a solid architecture that leverages React, TypeScript, and Redux to build maintainable, scalable, and performant applications.
1. Component-Based Architecture with TypeScript
Focus on Reusable and Modular Components
Breaking down the UI into small, reusable components is key to maintainability. Each component should have a single responsibility, keeping our application modular and easy to test.
Component Structure
We should use functional components with TypeScript interfaces or types for props, ensuring that each component's inputs are well-defined.
interface ButtonProps {
label: string;
onClick: () => void;
}
const Button: React.FC<ButtonProps> = ({ label, onClick }) => (
<button onClick={onClick}>{label}</button>
);
Atomic Design
Organizing components following the atomic design principle (atoms, molecules, organisms) helps create a structured and scalable hierarchy. For example, atoms could be buttons or inputs, molecules could be form elements, and organisms could be complete forms or sections of the UI.
Leverage TypeScript for Strong Typing
TypeScript allows us to define strict types for props, state, and actions, reducing bugs and enhancing code clarity. It enforces compile-time type checks, which ensures that our code is more reliable.
interface User {
id: number;
name: string;
email: string;
}
const UserComponent: React.FC<{ user: User }> = ({ user }) => (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
Avoid Overuse of State in Components
Using local state (useState
) in components can lead to overly
complex components. Instead, we should handle complex state logic with Redux,
reducing component bloat and keeping them stateless when possible.
2. State Management with Redux
Use Redux Toolkit for Efficient Redux Setup
Redux can add a lot of boilerplate to our project. The Redux Toolkit simplifies this by providing a set of tools and best practices to reduce boilerplate and improve code readability.
Slices
We can use createSlice
from Redux Toolkit to manage state in a
single feature module, containing action creators and reducers in one place.
This aligns with the Ducks pattern, grouping related logic
together.
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface CounterState {
value: number;
}
const initialState: CounterState = { value: 0 };
const counterSlice = createSlice({
name: 'counter',
initialState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
setCount: (state, action: PayloadAction<number>) => {
state.value = action.payload;
},
},
});
export const { increment, decrement, setCount } = counterSlice.actions;
export default counterSlice.reducer;
Use useSelector
and useDispatch
Hooks with Typed
State
To ensure type safety when accessing Redux state and dispatching actions, we should define custom hooks using TypeScript.
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { RootState, AppDispatch } from './store';
// Custom hooks for typed state and dispatch
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
This way, when we use useSelector
or useDispatch
in
our components, they will be type-safe and easy to work with.
const count = useAppSelector((state) => state.counter.value);
const dispatch = useAppDispatch();
dispatch(increment());
Keep State Shape and Reducers Simple
We should avoid storing derived or computed values in our Redux state. Keeping
our state normalized and simple, and deriving data inside selectors or
components as needed is better. Using libraries like reselect
for
memoized selectors can also improve performance.
import { createSelector } from 'reselect';
const selectUsers = (state: RootState) => state.users;
const selectActiveUsers = createSelector(selectUsers, (users) =>
users.filter((user) => user.active)
);
3. Directory Structure and Code Organization
Feature-Based Directory Structure
A feature-based directory structure helps keep the codebase modular and organized. We should group components, Redux slices, and services by feature rather than type.
src/
├── features/
│ ├── users/
│ │ ├── components/
│ │ ├── services/
│ │ ├── store/
│ │ └── UserContainer.tsx
├── app/
│ ├── store.ts
│ └── rootReducer.ts
├── components/
├── services/
├── App.tsx
└── index.tsx
This structure keeps related files together and makes the application more maintainable as it grows.
Use Absolute Imports
We should avoid deep relative imports by configuring absolute imports. This improves code readability and makes refactoring easier.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": "src"
}
}
Now we can import modules directly without relative paths:
import Button from 'components/Button';
4. Middleware and Side Effects
Handle Asynchronous Logic with Redux Middleware
For handling asynchronous actions, Redux Toolkit comes with
createAsyncThunk
, which simplifies handling side effects like API calls. This avoids the need
for manually setting up middleware like redux-thunk
or
redux-saga
.
import { createAsyncThunk } from '@reduxjs/toolkit';
export const fetchUserById = createAsyncThunk(
'users/fetchByIdStatus',
async (userId: string, thunkAPI) => {
const response = await fetch(`/api/users/${userId}`);
return response.json();
}
);
In our component, we can dispatch the thunk like any other action:
const dispatch = useAppDispatch();
dispatch(fetchUserById('123'));
Consider Redux Toolkit Query (RTK Query) for Data Fetching
If we want to manage server-side data fetching efficiently, RTK Query can be an excellent option. It helps with caching, background updates, and optimizing our data-fetching layer.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
endpoints: (builder) => ({
getUserById: builder.query<User, string>({
query: (id) => `users/${id}`,
}),
}),
});
export const { useGetUserByIdQuery } = api;
We can then use the hook in our component:
const { data, error } = useGetUserByIdQuery('123');
Summary
Following these best practices when designing our React application with TypeScript and Redux will ensure a scalable and maintainable codebase. Focusing on reusable components, leveraging TypeScript’s type system, using Redux Toolkit for state management, and organizing our code efficiently will help us build robust and performant applications.
Post a Comment