loading...
Cover image for You Might not need Redux

You Might not need Redux

jaffparker profile image Jaff Parker ・4 min read

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 connecting 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.

Discussion

markdown guide
 

There's no need to useCallback in AppStatusContainer, you can move the functions directly into useEffect. Also, it's missing the dependency array.