When dealing with a legacy React/React Native codebase, it is not uncommon to come across the usage of vanilla Redux in various aspects, such as managing client-state, handling side effects with thunks/sagas, or even managing server-state.
You can easily find tons of boilerplate for actions, reducers, and maintaining a central store, which can be time-consuming and error-prone. Additionally, vanilla Redux does not offer built-in solutions for handling asynchronous actions, leading developers to rely on middleware libraries like redux-thunk or redux-saga.
As technology evolves and newer approaches and libraries emerge, it becomes essential to consider migrating from vanilla Redux to a more modern approach.
Separation of client-state vs async server-state management is highly recommended in modern approach. There're many solutions for client-state management like Redux/Redux-Toolkit, Zustand, Jotai or Recoil from Meta/FB. On the server side, options are also affluent: RTK-Query, SWR, Apollo Client, and TanStack Query.
Recently, I had the opportunity to revamp our legacy code base, which heavily relies on vanilla Redux for managing both client and server state. While there was a desire to work with a newer tech stack, it became apparent that, for the time being, leveraging the current code base was the most practical approach. Therefore, we will be sticking with the legacy vanilla Redux for the time being. However, this presents a valuable opportunity to implement a 'separation of concerns' approach, utilizing Redux for client-state management while finding a suitable solution for asynchronous state management.
Since we are already using Redux, opting for Redux Toolkit (RTK) seems like a logical choice. This blog further strengthens our confidence in this decision.
But...
TanStack Query was selected because I wanted to minimize the setup of additional boilerplate code, even though it has been significantly reduced with RTK. I also desired a separate handling of server state. After researching and exploring various options, I found TanStack Query to be simpler, with well-documented resources. Another factor influencing my decision was the complexity involved in setting up the endpoints when working with APIs from multiple sources. TanStack Query offered a more straightforward and streamlined approach. It is happy as long as it gets a Promise.
Enough talk, show me some code already!
Here is simplified code which will do HTTP request to get Feed, and also Cards in certain condition. The result will be either error or success, along with the corresponding data, and appropriate actions will be dispatched.
// A typical thunk to fetch feed from a HTTP Api
const getFeed = (filter: FilterKey) => (dispatch) => {
dispatch({ type: 'FEED_WILL_REFRESH' })
Api.getFeed(filter).then((res) =>
res.either(
(error) =>
dispatch({
type: 'FEED_DID_REFRESH_ERROR',
error,
}),
(feed) => {
if (filter === 'recent') {
// also fetch cards if filter is 'recent'
dispatch(getCards(feed))
} else {
dispatch({
type: 'FEED_DID_REFRESH',
feed,
})
}
}
)
)
}
// Fetch cards
const getCards = (feed: Feed) => (dispatch) => {
dispatch({ type: 'FEED_WILL_GET_CARDS' })
Api.getCards().then((res) =>
res.either(
(error) =>
dispatch({
type: 'FEED_DID_REFRESH_ERROR',
error,
}),
(cards) =>
dispatch({
type: 'FEED_DID_REFRESH',
feed: { ...feed, cards },
})
)
)
}
// ...
// other code for reducers or API request are removed for simplicity purpose.
Equivalent code with RTK :)
// Async thunk for fetching the Feed
export const fetchFeedAsync = createAsyncThunk('feed/fetchFeed', async () => {
const response = await Api.getFeed(...);
return response;
});
// Async thunk for fetching Cards
export const fetchCardsAsync = createAsyncThunk('cards/fetchCards', async () => {
const response = await Api.getFeed();
return response;
});
// Slice for managing the Feed state
const feedSlice = createSlice({
name: 'feed',
initialState: {
data: null,
loading: false,
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchFeedAsync.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchFeedAsync.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchFeedAsync.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
// Slice for managing the Cards state
const cardsSlice = createSlice({
name: 'cards',
initialState: {
data: null,
loading: false,
error: null,
},
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchCardsAsync.pending, (state) => {
state.loading = true;
state.error = null;
})
.addCase(fetchCardsAsync.fulfilled, (state, action) => {
state.loading = false;
state.data = action.payload;
})
.addCase(fetchCardsAsync.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
Equivalent code with TanStack Query:
...
// Fetch Feed with useQuery
const useFeedQuery = (filter: FilterKey) =>
useQuery({
queryKey: ['feed', filter],
queryFn: () => Api.getFeed(filter),
})
// and fetching Cards - only "enabled" if filter is "recent"
const useCardsQuery = (filter: FilterKey) =>
useQuery({
queryKey: ['cards', filter],
queryFn: Api.getCards,
enabled: filter === 'recent',
})
As you can see, the code is much clearer and contains less boilerplate when using TanStack Query. Additionally, with TanStack Query, if the filter is set to 'recent', both queries will run in parallel instead of in a cascade, as was the case with the legacy code. This enhances performance and allows for more efficient data retrieval.
loading and error states are given by TanStack Query out of the box. Additionally, it serves as a robust global state manager. If you call the query with the same queryKey in multiple places, you will consistently receive the same data. TanStack Query also deduplicates requests that occur simultaneously, resulting in fewer network requests and faster response times. This helps optimize network and improves overall performance.
The end result of this simple migration is highly satisfying, with ~ 1000 line of legacy Redux code is removed. The majority of these code consisted of vanilla Redux boilerplate, which is no longer necessary with the adoption of TanStack Query. This reduction in code not only simplifies the codebase but also improves maintainability and readability, making it easier to work with.
Action CreatorsMiddlewaresReducersLoading/Error/Result states
All of them are provided by React Query with just a few lines of code:
const { isLoading, isError, error, data } = useFeedQuery(...)
Another advantage of adopting TanStack Query is its ability to coexist with our legacy vanilla Redux codebase. This allows for a gradual migration process, enabling us to make improvements over time without the need for a complete overhaul. Having TanStack Query alongside our legacy Redux provides a clear path forward and a proper solution for enhancing our state management. It brings us confidence that we can incrementally improve our codebase, making the transition from legacy Redux to a more efficient and streamlined solution less daunting and more manageable.
That's it for today. In the next post, I will focus on comparing performance before and after, examining how this simple migration has made a positive impact. Let me know if you have any specific areas you would like me to focus on for further polishing.
Top comments (0)