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.
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.
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.
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.
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.
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.
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.
Want to conquer your next JavaScript interview? Download my FREE PDF - Pass Your JS Interview with Confidence and start preparing for success today!
📚 Source code of what we've done