How to subscribe to state outside React component in Redux Toolkit?

I have the following slice:

export const authenticationSlice = createSlice({
    name: 'authentication',
    initialState: {
        isFirstTimeLoading: true,
        signedInUser: null
    },
    reducers: {
        signOut: (state) => {
            state.signedInUser = null
        },
        setUserAfterSignIn: (state, action: PayloadAction<SignInResult>) => {
            // some logic...
            state.signedInUser = {...}
        }
    },
    extraReducers: builder => {
        // Can I subscribe to signedInUser changes here?
    }
})

Is there a way I can subscribe to when signedInUser changes (setUserAfterSignIn and signOut), inside extraReducers?

For example everytime the setUserAfterSignIn action is dispatched I want to add an interceptor in axios that uses the user’s accessToken as a Auth header.

Can I also subscribe to this state from antoher slice? if some state in a different slice depends on signedInUser?

EDIT: Here is the thunk that signs in a user, and one that signs out

export const { signOut: signOutAction, setUserAfterSignIn: setUserAction } = authenticationSlice.actions

export const signInWithGoogleAccountThunk = createAsyncThunk('sign-in-with-google-account', async (staySignedIn: boolean, thunkAPI) => {
    const state = thunkAPI.getState() as RootState
    state.auth.signedInUser && await thunkAPI.dispatch(signOutThunk())
    const googleAuthUser = await googleClient.signIn()
    const signedInUser = await signInWithGoogleAccountServer({ idToken: googleAuthUser.getAuthResponse().id_token, staySignedIn })
    thunkAPI.dispatch(setUserAction({ data: signedInUser.data, additionalData: { imageUrl: googleAuthUser.getBasicProfile().getImageUrl() } } as SignInResult))
})

export const signInWithLocalAccountThunk = createAsyncThunk('sign-in-with-local-account', async (dto: LocalSignInDto, thunkAPI) => {
    const state = thunkAPI.getState() as RootState
    state.auth.signedInUser && await thunkAPI.dispatch(signOutThunk())
    const user = await signInWithLocalAccountServer(dto)
    thunkAPI.dispatch(setUserAction({ data: user.data } as SignInResult))
})

export const signOutThunk = createAsyncThunk<void, void, { dispatch: AppDispatch }>('sign-out', async (_, thunkAPI) => {
    localStorage.removeItem(POST_SESSION_DATA_KEY)
    sessionStorage.removeItem(POST_SESSION_DATA_KEY)

    const state = thunkAPI.getState() as RootState
    const signedInUser = state.auth.signedInUser

    if (signedInUser?.method === AccountSignInMethod.Google)
        await googleClient.signOut()
    if (signedInUser)
        await Promise.race([signOutServer(), rejectAfter(10_000)])
            .catch(error => console.error('Signing out of server was not successful', error))
            .finally(() => thunkAPI.dispatch(signOutAction()))
})

Answer

Redux implements the flux architecture.

Flux architecutre

This structure allows us to reason easily about our application in a way that is reminiscent of functional reactive programming, or more specifically data-flow programming or flow-based programming, where data flows through the application in a single direction — there are no two-way bindings.

Reducers should not be dependent on each other, because redux does not ensure a specific order in which they are executed. You can work around this by using combineReducer. You can not be sure that the extraReducers are executed after the setUserAfterSignIn reducer.

The options you have are:

  1. Put the code that updates the axios interceptor in the setUserAfterSignIn reducer.

     setUserAfterSignIn: (state, action: PayloadAction<SignInResult>) => {
         // some logic...
         state.signedInUser = {...}
         // update axios
     }
    
  2. Create the axios interceptor and pass it a supplier that is connected to the store. This way you can replace the way tokens are supplied easily.

     const tokenSupplier = () => store.getState().signedInUser;
    
     // ...
    
     axios.interceptors.request.use(function (config) {
         const token = tokenSupplier();
         config.headers.Authorization =  token;
    
         return config;
     });
    
  3. Extract two reducer functions and ensure their order.

    function signInUser(state, action) {
        state.signedInUser = {...}
    }
    
    function onUserSignedIn(state, action) {
        // update axios interceptor
    }
    
    // ....
    // ensure their order in the redux reducer.
    
    setUserAfterSignIn: (state, action: PayloadAction<SignInResult>) => {
         signInUser(state, action);
         onUserSignedIn(state, action)
    }
    

EDIT

Given this architecture, What are my options if I have another slice that needs to react when signedInUser has changed?

I guess you will not like the answer. I struggled with the same issue some time ago.

Another slice is an independent part in the store. You can add extra reducers that can listen to actions from other slices, but you can not be sure that the other slice’s reducer has already updated the state.

Let’s assume you have a slice A and a reducer RA and a slice B with a reducer RB. If the state B depends on A it means that the reducer RB should execute whenever A changes.

You can RA call RB, but this introduces a dependency to RB. It would be nice if RA could dispatch an action like { type: "stateAChanged", payload: stateA} so that other slices can listen to that action, but reducers can not dispatch actions. You can implement a middleware that augments actions with a dispatcher. E.g.

function augmentAction(store, action) {
  action.dispatch = (a) => {
     store.dispatch(a)
  }
  store.dispatch(action)
}

so that the reducers can dispatch actions.

setUserAfterSignIn: (state, action: PayloadAction<SignInResult>) => {
    // some logic...
    state.signedInUser = {...}
    action.dispatch({type : "userSignedIn": payload: {...state}})
}

But this approach is not a standard approach and if you excessively use it, you might introduce cycles that lead to endless loops in the dispatch.

Instead of using different slices some use different stores and connect them using the store’s subscribe. This is an official AP, but it can also introduce loops if you don’t pay enough attention.

So finally the simplest approach is to just call RB from RA. You can manage the dependency between them a bit by reversing it. E.g.

const onUserSignedIn = (token) => someOtherReducer(state, { type: "updateAxios", payload: token});

setUserAfterSignIn: (state, action: PayloadAction<SignInResult>) => {
    // some logic...
    state.signedInUser = {...}
    onUserSignedIn(state.signedInUser.token)
}

Now you can replace the onUserSignedIn callback in tests or with a composite functions that calls other registered callbacks.

EDIT

I’m currently working on a middleware library to solve our issue. I published my actual version of my library on Github and npm. The idea is that you describe the dependencies between states and actions that should be dispatched on change.

stateChangeMiddleware
  .whenStateChanges((state) => state.counter)
  .thenDispatch({ type: "text", payload: "changed" });