React Project Outgrowing Expectations? Learn These Basic Principles to Manage Better
Consequences of Poor Code Maintenance
Imagine building a product with great potential, but watching it progress slowly due to messy code and disorganized thinking.
We are a small team, building a product called Hexmos Feedback, Feedback helps keep teams motivated and engaged through recognition and continuous feedback.
We have attendance management as a part of Hexmos Feedback.
Hexmos Feedback goes beyond simple attendance – it helps identify committed employees who are present but may not be fully invested in the organization's goals.
We opted for React, known for its ease of use and scalability, as the foundation for our product's front end.
We were focused on getting features out the door, screens looking good, and functionality working. In that initial sprint, coding standards weren't exactly a priority.
Looking back, we just threw components wherever they fit, integrated APIs directly into component files (except for that base URLs!), and cobbled together structures that barely held things together.
Again, it wasn't the worst approach for a small team focused on a bare-bones MVP.
Growing Pains: The Need for Scalability
But then, things changed. As our capabilities matured a little bit, we started training students from various colleges, and we brought on some committed interns to help us scale our team.
That's when the cracks began to show. Our once "functional" codebase became difficult for these newbies to navigate.
Simple bug fixes turned into hour-long hunts just to find the relevant code.
It was a wake-up call. Our codebase was hindering progress and killing developer morale. That's when I knew we had to make a change.
Learning From Our Mistakes: Embracing Best Practices
It was time to learn some best practices, clean up the mess, and build a codebase that was scalable, maintainable, and wouldn't make our interns tear their hair out.
Organizing for Scalability
It took me more than two hours to organize the components, fix the import errors, and things got slightly better.
I understood the importance of a well-structured codebase for easy scalability and maintenance.
Most of the code should be organized within feature folders.
Each feature folder should contain code specific to that feature, keeping things neatly separated.
This approach helped prevent mixing feature-related code with shared components, making it simpler to manage and maintain the codebase compared to having many files in a flat folder structure.
How Redux Simplified API Integration
Remember that initial focus on getting features out the door? While it worked for our simple MVP, things got hairy as we added more features.
Our initial approach of making API calls directly from component files became a burden.
As the project grew, managing loaders, errors, and unnecessary API calls became a major headache.
Features were getting more complicated, and our codebase was becoming a tangled mess.
Component file lines increased, business logic (API calls, data transformation) and UI logic (displaying components, handling user interactions) got tangled, and maintaining the codebase and complexity to handle loaders, errors, etc became a nightmare.
As our project matured and the challenges of managing the state grew, I knew it was time to implement a better approach.
I started using Redux for API calls, a state management library for JavaScript applications, along with thunks (like createSlice, and createAsyncThunk from @reduxjs/toolkit) to handle this problem.
This is a strong approach for complex applications as Redux acts as a central hub for your application's state, promoting separation of concerns and scalability.
Here's why Redux was a game-changer
1. Centralized State Management
Redux keeps all your application's state in one central location. This makes it easier to track changes and manage loading and error states, in any component. No more hunting through a maze of component files!
2. Separation of Concerns
Thunks and slices (Redux's way of organizing reducers and actions) allow you to cleanly separate your business logic (API calls, data transformation) from your UI logic (displaying components, handling user interactions). This makes the code more readable, maintainable, and easier to test.
3. Consistency
Redux enforces a consistent way to handle API calls and manage state. This leads to cleaner, more maintainable code across your entire project.
4. Caching for Efficiency
Redux can help with caching and memoization of data, reducing unnecessary API calls and improving performance.
We'll dive into a concrete example in the next section to show you how Redux and thunks can be implemented to tame the API beast in your own React projects.
How to reduce burden using Redux
Here is how I got started with the conversion.
Considering you have already installed reduxjs/toolkit.
The Below image is how I have organized the files for redux in my streak feature.
Async Actions with Redux Thunk (actionCreator.ts
)
First, create a file actionCreator.js
and add a thunk for the API call of your component.
This file defines an asynchronous thunk action creator named fetchIndividualStreak
using createAsyncThunk
from @reduxjs/toolkit
.
Thunks are middleware functions that allow us to perform asynchronous operations (like API calls) within Redux actions.
It handles both successful and unsuccessful responses:
- On success, it returns the response data.
- On failure, it rejects the promise with an error message.
// actionCreator.ts
import { createAsyncThunk } from "@reduxjs/toolkit";
export const fetchIndividualStreak = createAsyncThunk(
"streak/fetchIndividualStreak",
async (memberId, { rejectWithValue }) => {
try {
const data = {
member_id: memberId,
};
const response = await apiWrapper({
url: `${urls.FB_BACKEND}/v3/member/streak`,
method: "POST",
body: JSON.stringify(data),
});
return response;
} catch (error) {
return rejectWithValue(error.message);
}
}
);
Centralized State (reducer.ts
)
This file defines a slice using createSlice
from @reduxjs/toolkit
.
A slice groups related reducers and an initial state for a specific feature (streak in this case).
The initial state includes properties for:
loading
: indicates if data is being fetcheddata
: holds the fetched streak informationerror
: stores any errors encountered during the API call
// reducer.ts
import { createSlice } from "@reduxjs/toolkit";
import { fetchIndividualStreak } from "./actionCreator";
const streakSlice = createSlice({
name: "streak",
initialState: {
loading: false,
data: null,
error: null,
},
reducers: ...,
extraReducers: ...,
});
State Flow During Execution
Initial Render: Before API stats executing
When the component mounts, the streak slice's initial state is used.
This means loading
is initially false, data
is null, and error
is null.
The UI won't display a loader or any streak information at this point.
Fetching Data: Triggering API fetching
The UI component dispatches the fetchIndividualStreak
action using useDispatch
.
This triggers the asynchronous thunk functionality within the action creator.
Pending State: API is already triggered
While the API call is in progress, the fetchIndividualStreak.pending
case in the reducer is triggered.
This sets the loading state to true and clears any existing error
.
The UI can now display a loader to indicate that data is being fetched.
// reducer.ts
const streakSlice = createSlice({
extraReducers: (builder) => {
/*----------- Handle Pending State ------------------------*/
builder.addCase(fetchIndividualStreak.pending, (state) => {
state.loading = true;
state.error = null;
});
},
});
Success (Fulfilled State): API success
If the API call is successful, the fetchIndividualStreak.fulfilled
case is triggered.
This sets the loading
state back to false and update the data
state with the fetched streak information.
The component's useEffect
hook that monitors the data
state will then trigger a re-render, and the UI can use the updated data
to display the necessary information.
// reducer.ts
const streakSlice = createSlice({
extraReducers: (builder) => {
/*----------- Handle Fullfiled State ------------------------*/
builder.addCase(fetchIndividualStreak.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
});
},
});
Failure (Rejected State): API failed
If the API call fails, the fetchIndividualStreak.rejected
case is triggered.
This sets the loading
state back to false and update the error
state with the error message from the API.
The UI can conditionally render an error message based on the presence of an error in the state.
// reducer.ts
const streakSlice = createSlice({
name: "streak",
initialState: ...initialState,
reducers: {
setStreakPackage: (state, action) => {
state.data = action.payload;
},
},
extraReducers: (builder) => {
/*----------- Handle Pending State -------------------------*/
builder.addCase(fetchIndividualStreak.pending, (state) => {
state.loading = true;
state.error = null;
})
/*----------- Handle Fullfiled State ------------------------*/
builder.addCase(fetchIndividualStreak.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
/*----------- Handle Rejected State ------------------------*/
builder.addCase(fetchIndividualStreak.rejected, (state, action) => {
state.loading = false;
state.error = action.payload
});
},
});
export const { setStreakPackage } = streakSlice.actions;
export default streakSlice.reducer;
By managing the state transitions within Redux reducers, we can efficiently communicate loading states, errors, and successful data retrieval to the React components, enabling them to update the UI accordingly. This centralized approach to state management and API interactions leads to a more responsive and user-friendly experience.
Connecting the Dots in the Component (IndividualStreak.tsx)
This component displays the user's streak information on a calendar.
It uses useDispatch
from React-Redux to dispatch actions and useSelector to access the Redux state.
The initial useEffect
hook triggers the fetchIndividualStreak
action to retrieve streak data for the specified member ID.
// IndividualStreak.tsx
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import { fetchIndividualStreak } from "./redux/actionCreator";
import { setStreakPackage } from "stores/slices";
function IndividualStreak({ memberId }) {
const dispatch = useDispatch();
const { loading, data: streakData, error } = useSelector((state: any) => state.streak);
useEffect(() => {
dispatch(fetchIndividualStreak(memberId));
}, [memberId, dispatch]);
A second useEffect
hook conditionally dispatches the setStreakPackage
reducer only if data is successfully fetched and has feedback information.
useEffect(() => {
if (
streakData &&
streakData.feedback_data &&
streakData.feedback_data.length > 0
) {
dispatch(setStreakPackage(streakData.feedback_data));
}
}, [streakData, dispatch]);
The component conditionally renders a loading bar, error message, or calendar component based on the state (loading
, error
, and streakData
).
// IndividualStreak.tsx
import Box from "@mui/material/Box";
import LinearProgress from "@mui/material/LinearProgress";
import moment from "moment";
import React, { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
import CalendarWrap from "../CalendarWrap";
import { fetchIndividualStreak } from "./redux/actionCreator";
import { setStreakPackage } from "stores/slices";
import Alert from "@mui/material/Alert";
function IndividualStreak({ memberId }) {
const dispatch = useDispatch();
const {
loading,
data: streakData,
error,
} = useSelector((state: any) => state.streak);
useEffect(() => {
dispatch(fetchIndividualStreak(memberId));
}, [memberId, dispatch]);
useEffect(() => {
if (
streakData &&
streakData.feedback_data &&
streakData.feedback_data.length > 0
) {
dispatch(setStreakPackage(streakData.feedback_data));
}
}, [streakData, dispatch]);
return (
<div>
{loading && (
<Box sx={{ width: "100%" }}>
<LinearProgress />
</Box>
)}{" "}
{error && <Alert severity="error">{error}</Alert>}
{streakData && (
<div style={{ width: "50%" }}>
<CalendarWrap
startDate={moment().subtract(12, "month")}
endDate={moment()}
streakData={streakData.feedback_data}
/>
</div>
)}
</div>
);
}
export default IndividualStreak;
This simplified example showcases the power of Redux for state management and API interactions in React applications.
Centralizing state and handling API calls within Redux leads to several benefits:
Improved Separation of Concerns
Business logic (API calls, data transformation) is separated from UI logic (displaying components, handling user interactions). This makes code cleaner and easier to maintain.
Enhanced Maintainability
Redux actions and reducers are easier to test and manage compared to components with embedded API logic.
Scalability
The Redux approach scales well for handling complex features like pagination, searching, and filtering, as these functionalities can leverage centralized state management.
By offloading the responsibility of managing loaders, errors, and API success/failure from components to Redux, we achieve a cleaner and more maintainable codebase. This approach lays the foundation for building robust and scalable React applications.
FeedZap: Read 2X Books This Year
FeedZap helps you consume your books through a healthy, snackable feed, so that you can read more with less time, effort and energy.
The Final Touches
As we wrapped our heads around Redux and implemented a solid code structure, we knew there was still room for improvement.
One area that constantly caused friction during code reviews was formatting inconsistencies.
Interns had different preferences for code formatters and styling configurations, leading to a sea of unnecessary changes cluttering the review process.
This is where pre-commit hooks entered the scene. We implemented Husky, a popular tool that allows us to run scripts before a commit is made.
This ensures that everyone's code adheres to the same style guidelines, reducing noise in code reviews and preventing potential bugs that might arise from inconsistent formatting.
Want to see a deeper dive into implementing these practices with code examples? Check out the full implementation details in the following sections of Auto-Formatting with Prettier, Husky & lint-staged on Every Git Commit blog post.
Conclusion
By embracing these practices, we've transformed our codebase from a tangled mess into a well-structured, maintainable, and scalable project.
This not only makes our lives easier but also ensures a smoother experience for our interns and future developers who join the team.
This concludes our exploration of the challenges and solutions we encountered while building our product.
I hope this narrative serves as a roadmap for those embarking on similar endeavors, helping you avoid the pitfalls we faced and build robust, maintainable applications.
Stay ahead of the curve! Subscribe for a weekly dose of insights on development, IT, operations, design, leadership and more.