While working on WildSync’s onboarding flow, I noticed something common and a frustrating issue, which is the prop drilling.
Components like the StepContent
had to accept state and setState functions for almost every piece of the data and pass them down the tree:
Before (lots of props):
const StepContent = ({
activeStep,
activities,
setActivities,
selectedActivityCard,
setSelectedActivityCard,
skillLevel
setSkillLevel
handleStepZeroSubmit,
stepZeroRef,
}: StepContentProps) => {
switch (activeStep) {
case 0:
return (
<OnboardingStepForm
handleStepZeroSubmit={handleStepZeroSubmit}
stepZeroRef={stepZeroRef}
/>
);
case 1:
return <StepCardsSelection />;
case 2:
return (
<StepSkillLevelSelection
skillLevel={skillLevel}
setSkillLevel={setSkillLevel}
activities={activities}
setActivities={setActivities}
selectedActivityCard={selectedActivityCard}
setSelectedActivityCard={setSelectedActivityCard}
/>
);
Now:
const StepContent = ({
activeStep,
handleStepZeroSubmit,
stepZeroRef,
}: StepContentProps) => {
switch (activeStep) {
case 0:
return (
<OnboardingStepForm
handleStepZeroSubmit={handleStepZeroSubmit}
stepZeroRef={stepZeroRef}
/>
);
case 1:
return <StepCardsSelection />;
case 2:
return (
<StepSkillLevelSelection
/>
);
It looks much cleaner now, doesn’t it?
When I finished the sign-up/onboarding process, I decided to refactor the code and get familiar with the Redux Toolkit, and replace some of the local state that I was drilling through components with Redux Toolkit.
Why Redux Toolkit?
I considered the following options:
- useContex
- Redux Toolkit
useContext: Great for sharing simple state, for example, dark/light theme, languages, etc. However, it is more read-focused, and it doesn’t provide tools for updating or managing complex state logic.
Redux Toolkit: Suitable for large applications and has centralized logic that helps manage multiple onboarding steps in a predictive way. It also offers debugging via the Redux DevTools.
Even though my application is still early in the development process, I was curious about the Redux Toolkit and wanted to be prepared for the complexity that will come soon, such as the user sessions, events, etc, which will require the Redux Toolkit for sure, so why not get familiar with it right now?
The Skill Level Local State
At the beginning, I used the following state to store the user’s selected skill level for each activity the user chose:
export type SkillLevel = Record<number, string>;
const [skillLevel, setSkillLevel] = useState<SkillLevel>({});
Each time the user selected the skill level, I updated the state as follows:
setSkillLevel({
...skillLevel,
[activity.id]: event.target.value,
});
To do so, I had to pass the skillLevel
prop down multiple components.
Before I move on to the Redux Toolkit replacement, I would like to share how I interpreted it:
Redux Store: This is where all my app’s state lives.
It holds pieces of the state, where each of them is managed by the slice. Components can read or dispatch actions (update the state), so there is no need to prop drill the data through multiple components.
Redux Slice: A slice is a section that manages a specific state, including the initial state, reducers (how to update the state), and actions (what exactly triggers the update).
Each slice handles one piece of the application; for instance, the activitiesSkillLevelSlice.ts in my case, manages the skill level state and its logic.
useSelector: A hook that lets you read the data from the Redux store. It’s like a state reader for the component.
useDispatch: A hook that triggers the change in the state, in other words, is like a global version of the setState function, but works across the app.
Breakdown of the Redux Toolkit Slice
In the activitiesSkillLevelSlice.ts
I defined the initial state:
export interface ActivitiesSkillLevelGlobalState {
skillLevel: Record<number, string>;
}
const initialState: ActivitiesSkillLevelGlobalState = {
skillLevel: {},
};
I defined a reducer that updates the object by key:
const activitiesSkillLevelSlice = createSlice({
name: "activitiesSkillLevel",
initialState,
reducers: {
setSkillLevelForActivity(state: ActivitiesSkillLevelGlobalState, action) {
const { activityId, selectedSkillLevel } = action.payload;
state.skillLevel[activityId] = selectedSkillLevel;
},
},
});
Let’s break down the setSkillLevelForActivity
reducer;
It takes two parameters: the state (the current state) and the action (which contains the payload).
The payload includes two values, the activityId
and the selectedSkillLevel
, which stands for the ID of the selected activity and the user’s skill level.
The following line is the key here:
state.skillLevel[activityId] = selectedSkillLevel;
It dynamically assigns the skillLevel
based on the activityId
, for instance:
skillLevel[3] = "Intermediate";
This allows updating a single activity without touching the rest of the object.
Component changes
How the handleChange
looked with the local state:
const handleChange = (event) => {
setSkillLevel({
...skillLevel,
[activity.id]: event.target.value,
});
};
How it looks now with the Redux Toolkit:
dispatch(setSkillLevelForActivity({
activityId: activity.id,
selectedSkillLevel: event.target.value,
}));
And I read the data with the following:
const skillLevel = useAppSelector((state) => state.activitiesSkillLevel.skillLevel);
I made similar changes to other local states as well.
Learning Redux Toolkit can be overwhelming at first, but with every piece I refactor, it starts to click.
If you are new to Redux Toolkit like I was, start small, stay curious, and don’t hesitate to revisit the documentation, see some examples, and keep practicing. Your future self and your components will thank you later.