Redux is an awesome state management library. While being very minimalistic, it offers the structure and order that can easily be neglected in a React project. That's why I would automatically install it every time I start a React project. It would be my cache, my application state keeper and best friend.
Then I discovered Apollo Client that can manage cache for you without much overhead. That's when Redux' role was starting to reduce (pun intended) in my dev experience. Basically, I would only use it for authentication and network/server state.
And then React released hooks, including the useReducer
one, whose usage reminded me a lot of Redux... At this point I started to rethink whether I really need an entire extra dependency in my code to manage very little.
In this post I will describe my reasoning behind moving away from Redux and go through the process of migration. Hopefully it can help some of you keep an open mind about your favourite libraries and realise when it might be time to let go of them :)
Why leave Redux?
There are a few reasons that pushed me into exploring replacing Redux with hooks API.
First, I was installing an extra NPM package for just 4 actions and 2 states. That seemed very excessive and added complexity to the project. Besides, React offers everything you need for basic app state management. If you don't use it, it's a waste of code.
Second, I was starting to get annoyed with how complex typing connected components can get... I had to write a whole lot of extra code sometimes just to know whether the user is authenticated or not.
Third, like many, I instantly fell in love with React hooks and how well they tie into functional components (my second favourite thing in frontend dev after hooks themselves).
How did I leave Redux without breaking anything?
My Redux store had 2 reducers combined: auth
and appStatus
. First, let's see how I migrated auth
.
auth
state was simple:
interface AuthState {
isSignedIn: boolean
token: string
user: User
}
With it came 2 actions: signIn
and signOut
.
First thing that I noticed is that React's useReducer
hook has the same reducer signature as Redux. So the great thing is that you can totally reuse your reducers! However, I could not just put reducer in a context. I needed to be able to update it from the nested components, so I followed the tutorial from the official docs called Updating Context from a Nested Component (what a coincidence??). Thus this code was born:
// contexts/AuthContext.ts
export const AuthContext = createContext<AuthContextState>({
isSignedIn: false,
})
export const AuthProvider = AuthContext.Provider
// components/AuthContextContainer.tsx
import {
auth,
signIn as signInAction,
signOut as SignOutAction,
} from '../reducers/auth.ts'
export const AuthContextContainer: FC = ({ children }) => {
const [state, dispatch] = useReducer(auth)
const signIn = useCallback((user: User, token: string) => {
dispatch(signInAction(user, token))
}, [])
const signOut = useCallback(() => {
dispatch(signOutAction())
}, [])
return (
<AuthProvider value={{ ...state, signOut, signIn }}>
{children}
</AuthProvider>
)
}
Bam! There's the Redux auth store. Now to use it in my components instead of connect
ing them, I simply had to do this:
export const SignInContainer: FC = () => {
const { signIn } = useContext(AuthContext)
const onSubmit = async ({email, password}: SignInFormValues): void => {
const { token, user } = await getTokenAndUserFromSomewhere(email, password)
signIn(user, token)
}
return (
// ... put the form here
)
}
Now I can sign into the app and browse around! What happens though when I reload the page? Well, as you might've already guessed, the app has no idea I was ever signed in, since there's no state persistence at all... To handle that I modified the AuthContextContainer
to save the state into localStorage
on every change:
export const AuthContextContainer: FC = ({ children }) => {
// LOOK HERE
const initialState = localStorage.getItem('authState')
const [state, dispatch] = useReducer(
auth,
// AND HERE
initialState ? JSON.parse(initialState) : { isSignedIn: false },
)
const signIn = useCallback((user: User, token: string) => {
dispatch(signInAction(user, token))
}, [])
const signOut = useCallback(() => {
dispatch(signOutAction())
}, [])
// AND HERE
useEffect(() => {
localStorage.setItem('authState', JSON.stringify(state))
}, [state])
return (
<AuthProvider value={{ ...state, signOut, signIn }}>
{children}
</AuthProvider>
)
}
Now the useReducer
hook gets an initial state and it's persisted on every change using the useEffect
hook! I don't know about you, but I think it's awesome. A component and a context do exactly what an entire library used to do.
Now I'll show you what I did with the appStatus
state. appStatus
only had one job: watch for the network availability and store whether we're online or offline. Here's how it did it:
export const watchNetworkStatus = () => (dispatch: Dispatch) => {
window.addEventListener('offline', () =>
dispatch(networkStatusChanged(false)),
)
window.addEventListener('online', () => dispatch(networkStatusChanged(true)))
}
export interface AppStatusState {
isOnline: boolean
}
const defaultState: AppStatusState = {
isOnline: navigator.onLine,
}
export const appStatus = (
state: AppStatusState = defaultState,
action: AppStatusAction,
): AppStatusState => {
switch (action.type) {
case AppStatusActionTypes.NetworkStatusChanged:
return {
...state,
isOnline: action.payload.isOnline,
}
default:
return state
}
}
You can see that to watch for network status, I was using a thunk, which isn't offered by the useReducer
hook. So how did I handle that?
First, like before, I needed to create the context:
// contexts/AppStatusContext.ts
export const AppStatusContext = createContext({ isOnline: false })
export const AppStatusProvider = AppStatusContext.Provider
Then like for auth, I started writing a container that will handle the logic. That's when I realized that I don't even need a reducer for it:
// components/AppStatusContainer.tsx
export const AppStatusContainer: FC = ({ children }) => {
const [isOnline, setIsOnline] = useState(true)
const setOffline = useCallback(() => {
setIsOnline(false)
}, [])
const setOnline = useCallback(() => {
setIsOnline(true)
}, [])
useEffect(() => {
window.addEventListener('offline', setOffline)
window.addEventListener('online', setOnline)
return () => {
window.removeEventListener('offline', setOffline)
window.removeEventListener('online', setOnline)
}
})
return <AppStatusProvider value={{ isOnline }}>{children}</AppStatusProvider>
}
Thus I not only got rid of an extra dependency, but also reduced complexity! And that particular thunk could simply be replaced with a useEffect
hook.
That's how in a few short steps (and about an hour) I managed to reduce the size of my app bundle and get rid of some unnecessarily complex logic. The lesson here is that no matter how useful a library can be, it can and will happen that you don't need it. You just gotta keep and open mind about it and notice when it happens :)
I hope my experience will give some of you the courage to try new things and discover new dev experiences for yourselves!
PS: hooks are awesome! If you still haven't, you should totally start using them.
Top comments (1)
There's no need to
useCallback
inAppStatusContainer
, you can move the functions directly intouseEffect
. Also, it's missing the dependency array.