React Redux Private Route Best Practise

Viewed 371

What would be the best practice for private routing? Maybe I'm doing something wrong, but when user logged in I already am redirect to the /login page

And my second question: Which of these versions is better or you have even better idea?

Code:

Auth

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    user: {},
    isUserLoggedIn: null,
    isLoading: false,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(me.pending, (state) => {
        state.isLoading = true
      })
      .addCase(me.fulfilled, (state, action) => {
        state.user = action.payload.userData
        state.isUserLoggedIn = true
        state.isLoading = false
      })
      .addCase(me.rejected, (state) => {
        state.isUserLoggedIn = false
      })
  },
})

export const me = createAsyncThunk('auth/me', async () => {
  try {
    const user = await userService.getUserData()
    return { userData: user.data.data }
  } catch (error) {
    const message =
      (error.response && error.response.data && error.response.data.message) ||
      error.message ||
      error.toString()
    return message
  }
})

CASE 1:

App

function App() {
  const dispatch = useDispatch()

  useEffect(() => {
    dispatch(me())
  }, [])

  const auth = useSelector((state) => state.auth)

  return (
    <div data-theme={theme}>
      <BrowserRouter>
        <AppRoutes isAuthenticated={auth.isUserLoggedIn} />
      </BrowserRouter>
    </div>
  )
}

export default App

Routes

export const AppRoutes = ({ isAuthenticated }) => (
  <Routes>
    <Route
      path='/login'
      element={<Login />}
    />
    <Route
      path='/dashboard'
      element={
        <PrivateRoute isAuthenticated={isAuthenticated}>
          <Stats />
        </PrivateRoute>
      }
    />
    ...

PrivateRoute

export const PrivateRoute = ({ children, isAuthenticated }) => {
  return isAuthenticated ? children : <Navigate to='/login' />
}

CASE 2:

App

function App() {
  return (
    <div data-theme={theme}>
      <BrowserRouter>
        <AppRoutes />
      </BrowserRouter>
    </div>
  )
}

export default App

Routes

export const AppRoutes =  () => (
  <Routes>
    <Route
      path='/login'
      element={<Login />}
    />
    <Route
      path='/dashboard'
      element={
        <PrivateRoute>
          <Stats />
        </PrivateRoute>
      }
    />
  ...

PrivateRoute

export const PrivateRoute = ({ children }) => {
  const dispatch = useDispatch()
  const { isUserLoggedIn } = useSelector((state) => state.auth)

  useEffect(() => {
    if (isUserLoggedIn === null) {
      dispatch(me())
    }
  }, [])

  return isUserLoggedIn ? children : <Navigate to='/login' />
}

CASE 1 or CASE 2 is better to approach or maybe you have a better idea?

For these 2 ideas, it redirects very quickly to /login when I go to /dashboard

What I want to achieve is good practice, quick verification, and waiting until we receive a positive response from the backend that the user is authenticated

Team, what do you suggest?

EDIT

New version:

Redux

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    user: {},
    isUserLoggedIn: null,
    isLoading: true,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(me.pending, (state) => {
        state.isLoading = true
      })
      .addCase(me.fulfilled, (state, action) => {
        state.user = action.payload.userData
        state.isUserLoggedIn = true
        state.isLoading = false
      })
      .addCase(me.rejected, (state) => {
        state.isLoading = false
        state.isUserLoggedIn = false
      })
  },
})

export const me = createAsyncThunk('auth/me', async ({}, thunkAPI) => {
  try {
    const user = await userService.getUserData()
    return { userData: user.data.data }
  } catch (error) {
    const message =
      (error.response && error.response.data && error.response.data.message) ||
      error.message ||
      error.toString()
    thunkAPI.rejectWithValue(message)
    return message
  }
})

PrivateRoute

export const PrivateRoute = ({ children }) => {
  const dispatch = useDispatch()
  const { isLoading, isUserLoggedIn } = useSelector((state) => state.auth)

  if (isLoading) return null 

  return isUserLoggedIn ? (
    children
  ) : (
    <Navigate
      to='/login'
      replace
    />
  )
}

App

function App() {
  const dispatch = useDispatch()
  const { isUserLoggedIn } = useSelector((state) => state.auth)

  useEffect(() => {
    if (isUserLoggedIn === null) {
      dispatch(me())
    }
  }, [])

  return (
    <div data-theme={theme}>
      <BrowserRouter>
        <AppRoutes />
      </BrowserRouter>
    </div>
  )
}

export default App

Store

import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'
import authReducer from '../features/auth/authSlice'

export const store = configureStore({
  reducer: {
    auth: authReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({ serializableCheck: false }),
})

Index.js

ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
        <App />
    </Provider>
  </React.StrictMode>,
  document.getElementById('root')

But when I reload the page I go to /login don't stay at the same page like /dashboard how could I cope with that?

2 Answers

Either approach is fine. The issue you are seeing is based on the initial redux state value being used for auth check and redirection prior to the effect running to set the auth state. You will want to hold off on redirecting until the authentication status is determined. Use the state to rendering null or some loading indicator until a user is verified authenticated.

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    user: {},
    isUserLoggedIn: null,
    isLoading: true, // <-- assume initially loading state from mouting
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(me.pending, (state) => {
        state.isLoading = true
      })
      .addCase(me.fulfilled, (state, action) => {
        state.user = action.payload.userData
        state.isUserLoggedIn = true
        state.isLoading = false
      })
      .addCase(me.rejected, (state) => {
        state.isUserLoggedIn = false
      })
  },
});

Between the two implementations IMO the second is preferred as it leads to less component coupling. I suggest a mix of the two. Check for a user in App and dispatch the action to set the auth state, and check the auth state in the private route.

function App() {
  const dispatch = useDispatch();
  const { isUserLoggedIn } = useSelector((state) => state.auth);

  useEffect(() => {
    if (isUserLoggedIn === null) {
      dispatch(me());
    }
  }, []);

  return (
    <div data-theme={theme}>
      <BrowserRouter>
        <AppRoutes />
      </BrowserRouter>
    </div>
  );
}

...

export const PrivateRoute = ({ children }) => {
  const dispatch = useDispatch()
  const { isLoading, isUserLoggedIn } = useSelector((state) => state.auth)

  if (isLoading) return null; // <-- or loading spinner, etc...

  return isUserLoggedIn ? children : <Navigate to='/login' replace />
}

The same can be achieved with the first version, you'd just need to pass the isLoading state along with the isUserLoggedIn state as props to the PrivateRoute.

I found a problem! Ha ha!

Seems stupid && simple but it started working ->

Redux

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    user: {},
    isUserLoggedIn: null,
    isLoading: true,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(me.pending, (state) => {
        state.isLoading = true
      })
      .addCase(me.fulfilled, (state, action) => {
        state.user = action.payload.userData
        state.isUserLoggedIn = true
        state.isLoading = false
      })
      .addCase(me.rejected, (state) => {
        state.isLoading = false
        state.isUserLoggedIn = false
      })
  },
})

export const me = createAsyncThunk('auth/me', async (_, thunkAPI) => { // <- {} replaced to _ and this is it! 
  try {
    const user = await userService.getUserData()
    return { userData: user.data.data }
  } catch (error) {
    const message =
      (error.response && error.response.data && error.response.data.message) ||
      error.message ||
      error.toString()
    thunkAPI.rejectWithValue(message)
    return message
  }
})
Related