Redux Toolkit Authentication - Do It Right
Redux Toolkit Authentication - Do It Right

In this post you will learn how to implement Redux Toolkit authentication without any additional libraries.

Initial project

I already prepared a small project with empty register and logic form.

initial project

Here is how our register form looks like.

import Home from "./home/Home";
import Login from "./login/Login";
import Register from "./register/Register";

const App = () => {
  return (
    <div>
      <Link to="/">Home</Link>
      <Link to="/login">Login</Link>
      <Link to="/register">Register</Link>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/login" element={<Login />} />
        <Route path="/register" element={<Register />} />
      </Routes>
    </div>
  );
};

Here is our App component which registers routes to Login, Register and Home pages.

const Register = () => {
  return (
    <div>
      <h1>Register</h1>
      <form>
        <div>
          <input type="text" placeholder="Username" />
        </div>
        <div>
          <input type="text" placeholder="Email" />
        </div>
        <div>
          <input type="password" placeholder="Password"
          />
        </div>
        <div>
          <button type="submit">Sign Up</button>
        </div>
      </form>
    </div>
  );
};

This is how our Register looks like. As you can see it's just a plain HTML form.

Also I already prepared configuration of Redux toolkit.

import { configureStore } from "@reduxjs/toolkit";
import reducer from "./store/reducers";

const store = configureStore({ reducer });

ReactDOM.createRoot(document.getElementById("root")).render(
  <BrowserRouter>
    <Provider store={store}>
      <App />
    </Provider>
  </BrowserRouter>
);

Here we wrap our application in Provider and create a store with reducers.

// src/store/reducers/index.js
import auth from "./auth";

export default {
  auth,
};

Our reducers have just a single property auth that we need to implement.

const initialState = {
  currentUser: undefined,
  isLoading: false,
};
const authSlice = createSlice({
  name: "auth",
  initialState,
})
export default authSlice.reducer;

As you can see our state of auth contains 2 properties. isLoading and currentUser. There is no logic inside yet. The default state of currentUser is undefined. It means that we don't know yet if the user is logged in or not.

Building registration

The first thing that we need to implement is our registration. Our first step is to create local state for register form to be able to read them when we submit our form.

import { useState } from "react";
import { register } from "../store/reducers/auth";

const Register = () => {
  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const onSubmit = (e) => {
    e.preventDefault();
    console.log("register", username, email, password);
  };
  return (
    <div>
      <h1>Register</h1>
      <form onSubmit={onSubmit}>
        <div>
          <input
            type="text"
            placeholder="Username"
            onChange={(e) => setUsername(e.target.value)}
          />
        </div>

        <div>
          <input
            type="text"
            placeholder="Email"
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <div>
          <input
            type="password"
            placeholder="Password"
            onChange={(e) => setPassword(e.target.value)}
          />
        </div>
        <div>
          <button type="submit">Sign Up</button>
        </div>
      </form>
    </div>
  );
};

export default Register;

There is zero Redux here. We just created 3 different useState hooks and binded them to our inputs.

use state form

As you can see our state data are available when we submit the form and we can use them now.

Now we need to bind Redux to our register form. In order to do that you must understand what happens when we submit a form.

We want to make an API call and receive some response. Which actually means that registration is not a synchronous update inside our state. This is an asynchronous process. It means that we need to use asyncThunk for it and create multiple actions to cover this process.

// src/store/reducers/auth.js
export const register = createAsyncThunk(
  "auth/register",
  async (userData, thunkAPI) => {
    try {
      const response = await axios.post("https://api.realworld.io/api/users", {
        user: userData,
      });
      return response.data.user;
    } catch (err) {
      return thunkAPI.rejectWithValue(err.response.data.errors);
    }
  }
);
...

Here in our reducer we created an asynchronous action register by using createAsyncThunk. As a first parameter we provide inside a name and a second parameter must return a promise.

Here we wrote an async function which makes an API call to a realworld API with the userData that we will provide inside. Back it returns the registered user data. When error happens we use thunkAPI.rejectWithValue to provide error messages.

Now we must cover this actions in our reducer.

// src/store/reducers/auth.js
...
const authSlice = createSlice({
  name: "auth",
  initialState,
  extraReducers: (builder) => {
    builder
      .addCase(register.pending, (state) => {
        state.isLoading = true;
      })
      .addCase(register.fulfilled, (state, action) => {
        state.isLoading = false;
        state.currentUser = action.payload;
      })
      .addCase(register.rejected, (state) => {
        state.isLoading = false;
      })
  },
});

As you can see from asynchronous action we get pending, fulfilled and rejected actions on which we want to react. While pending we change isLoading to true to show loading indicator. If we have an error we remove a loading and we might save errors in our state later. In fulfilled we save the data that we got from API in our currentUser field.

Now we are fully ready to use these actions in our component.

const Register = () => {
  const navigate = useNavigate();
  const dispatch = useDispatch();
  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const onSubmit = (e) => {
    e.preventDefault();
    console.log("register", username, email, password);
    dispatch(register({ email, username, password })).then((action) => {
      localStorage.setItem("accessToken", action.payload.token);
      navigate("/");
    });
  };
  ...
}

Here we got dispatch through useDispatch hook. Then in onSubmit we dispatched register action and provided all data inside. Because dispatch returns an promise we can wait here for success and set a token of the user to localStorage and navigate a user to homepage.

User token is just a unique string to authenticate a user. We need to store token in order to authenticate our requests after page reload.

succ register

As you can see after registration we got user data, token, saved it and redirected a user to the homepage.

Building login

Now we must do exactly the same for our login page. We need to create an asynchronous action, update our reducer and apply same logic to the component.

export const login = createAsyncThunk(
  "auth/login",
  async (userData, thunkAPI) => {
    try {
      const response = await axios.post(
        "https://api.realworld.io/api/users/login",
        {
          user: userData,
        }
      );
      return response.data.user;
    } catch (err) {
      return thunkAPI.rejectWithValue(err.response.data.errors);
    }
  }
);

const authSlice = createSlice({
  name: "auth",
  initialState,
  extraReducers: (builder) => {
    builder
      ...
      .addCase(login.pending, (state) => {
        state.isLoading = true;
      })
      .addCase(login.fulfilled, (state, action) => {
        state.isLoading = false;
        state.currentUser = action.payload;
      })
      .addCase(login.rejected, (state) => {
        state.isLoading = false;
      })
  },
});

Here we did exactly the same stuff for login action. It makes an API call and returns user data. It also saves a user in reducer on success.

Now we need to update our login component which is 99% the same except of username field.

import { useState } from "react";
import { useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { login } from "../store/reducers/auth";

const Login = () => {
  const navigate = useNavigate();
  const dispatch = useDispatch();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const onSubmit = (e) => {
    e.preventDefault();
    console.log("login", email, password);
    dispatch(login({ email, password })).then((action) => {
      localStorage.setItem("accessToken", action.payload.token);
      navigate("/");
    });
  };
  return (
    <div>
      <h1>Login</h1>
      <form onSubmit={onSubmit}>
        <div>
          <input
            type="text"
            placeholder="Email"
            onChange={(e) => setEmail(e.target.value)}
          />
        </div>
        <div>
          <input
            type="password"
            placeholder="Password"
            onChange={(e) => setPassword(e.target.value)}
          />
        </div>
        <div>
          <button type="submit">Sign In</button>
        </div>
      </form>
    </div>
  );
};

export default Login;

We save here our inputs, dispatch a login action and save token to local storage.

login

As you can see our actions are triggered and we saved user data in the Redux.

Getting a user

If we reload the page all information disappears from Redux because it is stored only in memory. The only thing that stays is a token that we saved to local storage. Let's create a get current user action which must get a user after we reloaded a page.

export const getCurrentUser = createAsyncThunk(
  "auth/getCurrentUser",
  async (_, thunkAPI) => {
    try {
      const token = localStorage.getItem("accessToken") ?? "";
      const response = await axios.get("https://api.realworld.io/api/user", {
        headers: {
          Authorization: `Token ${token}`,
        },
      });
      return response.data.user;
    } catch (err) {
      return thunkAPI.rejectWithValue(err.response.data.errors);
    }
  }
);

const authSlice = createSlice({
  name: "auth",
  initialState,
  extraReducers: (builder) => {
    builder
      .addCase(getCurrentUser.pending, (state) => {
        state.isLoading = true;
      })
      .addCase(getCurrentUser.fulfilled, (state, action) => {
        state.isLoading = false;
        state.currentUser = action.payload;
      })
      .addCase(getCurrentUser.rejected, (state) => {
        state.isLoading = false;
        state.currentUser = null;
      })
  },
});

Here we created an async get current user action. It reads the token from local storage and put it in Authorization header for the backend. In this case backend knows which user data it must return.

Save as with login and register getting of the current user saves information in Redux.

Now we want to get currentUser when we initialize our application.

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

  useEffect(() => {
    dispatch(getCurrentUser());
  }, [dispatch]);
  ...
};

As our App is being called on any page we dispatch an action here.

get current user

Now after page reload we made an API call and got user information in Redux.

Showing user information

We also want to show user information on the top of the page.

const App = () => {
  ...
  const auth = useSelector((state) => state.auth);
  ...
  return (
    <div>
      <Link to="/">Home</Link>
      {auth.currentUser === null && (
        <Fragment>
          <Link to="/login">Login</Link>
          <Link to="/register">Register</Link>
        </Fragment>
      )}
      {auth.currentUser && (
        <span>Logout</span>
      )}
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/login" element={<Login />} />
        <Route path="/register" element={<Register />} />
      </Routes>
    </div>
  );
};

Here we used useSelector to access user information from the state. When user information equals null it means that we are not authorized. When we have user information we got and object and when it is undefined we don't know the state yet. Here we render links accordingly.

header

After page reload we see only Logout link and we don't see Login or Register because we are logged in.

Logout

The last thing to implement is our logout functionality. It is extremely easy to do as removing the local storage token is all we need. Additionally we also want to clean our Redux state.

export const logout = createAsyncThunk("auth/logout", async () => {
  localStorage.removeItem("accessToken");
});

const authSlice = createSlice({
  name: "auth",
  initialState,
  extraReducers: (builder) => {
    builder
      ...
      .addCase(logout.fulfilled, (state) => {
        state.isLoading = false;
        state.currentUser = null;
      });
  },
});

Our logout action just removes the token and when it happens we set our currentUser to null.

We also need to dispatch this action in header.

{auth.currentUser && (
  <span onClick={() => dispatch(logout())}>Logout</span>
)}

It is enough to correct implement logout.

And actually if you want to improve your React knowledge and prepare for the interview I highly recommend you to check my course React Interview Questions.

📚 Source code of what we've done