Basic react-redux setup that should work for any app using redux modern approach to state management. This will be a step by step guide without going too deeply into detail since it's mostly adding boilerplate.
The stack is Typescript, React, Redux, that's it!
I created a basic authReducer to manage session state and async calls.
Here's a repo with a fully working example, it also includes async calls using thunks.
Store
import { configureStore, ThunkAction, Action } from '@reduxjs/toolkit';
// This doesn't need to be in the 'reducers'
// directory, you can place the slice file
// inside your component directory, etc
import { authReducer } from '../reducers';
export const store = configureStore({
reducer: {
auth: authReducer
}
});
export type AppDispatch = typeof store.dispatch;
// Typescript utility type to infere the return type of a function,
// in this case we are doing type-inference of the store.getState method.
// If we keep adding reducers to our store, this will update types automatically :)
export type RootState = ReturnType<typeof store.getState>
export type AppThunk<ReturnType = void> = ThunkAction<
ReturnType,
RootState,
unknown,
Action
>;
Hooks
Create a file called hooks.ts
.
These hooks will allow you to dispatch redux actions and retrieve data from state (selector).
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';
export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
Creating a "slice"
The slice is the bread and butter of modern redux, it'll have the reducers, provide actions and asynchronous flows. We'll use createSlice
helper from @reduxjs/toolkit for this.
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
// The global app state types
import { RootState } from './store';
// Dummy async call to authenticate an user plus types
import { Auth, Session, authenticate } from '../services';
// This is not needed, but it's nice to have action names
// in constants that can be used globally
import { auth as authConstants } from './constants';
// We declare the types for this specific reducer
export interface AuthReducerState {
authenticating: boolean;
email?: string;
authData?: Session;
}
// Declare some initial state if required
const initialState: AuthReducerState = {
authenticating: false,
email: undefined,
authData: undefined
};
// A basic thunk to handle the async flow
// from here we are just invoking a promise
// that returns (or not!) an user session
export const loginAsync = createAsyncThunk(
authConstants.APP_AUTH_CREATE_SESSION,
async (loginData: Auth) => {
try {
const response = await authenticate(loginData);
return response;
} catch (e) {
console.error('WHOOPS SOMETHING WRONG IN loginAsync!!!')
console.error(e);
}
}
);
// The actual slice
export const authSlice = createSlice({
name: 'auth',
initialState,
// If we pass an object to the reducers key, redux
// automatically combines them using combineReducers
reducers: {
requesting(state) {
// Modern redux uses immer internally, so
// it's fine to mutate data on this manner,
// you always will get an immutable object in the end
state.authenticating = true;
},
// These are just for demo purposes, not needed here since
// we have a single case and it is an async one (create an user session)
// and those are being handled in the extraReducers section.
fulfilled(state) {
state.authenticating = false;
}
},
extraReducers: (builder) => {
builder
// Thunks is a promise, so we have all 3 states here being handled
// appropriately
.addCase(loginAsync.pending, (state) => {
state.authenticating = true;
})
.addCase(loginAsync.rejected, (state) => {
state.authenticating = false;
state.authData = undefined;
})
.addCase(loginAsync.fulfilled, (state, action) => {
state.authenticating = false;
state.authData = action.payload;
})
}
});
// Selectors
export const selectSessionData = (state: RootState) => state.auth.authData;
export const selectAuthenticating = (state: RootState) => state.auth.authenticating;
// Export actions from slice
export const authActions = authSlice.actions;
// Export reducer from slice
export const authReducer = authSlice.reducer;
The Login component
import React, { FormEvent, SyntheticEvent, useCallback, useState } from 'react';
import { useAppSelector, useAppDispatch } from './hooks';
import { Auth } from './services';
import { selectSessionData, selectAuthenticating, loginAsync } from './slices';
import './Login.css';
export const Login = () => {
const dispatch = useAppDispatch();
const authenticating = useAppSelector(selectAuthenticating);
const [auth, setAuth] = useState({
email: '',
password: ''
});
// Very basic validation just to make sure the
// user submitted data through the form and
// track the pending to fulfilled/rejected states.
//
// Web form validation should be a lot stronger,
// at least email syntax and password strength.
const disableSubmit = (authData: Auth | undefined) => !authData || !authData.email || !authData.password || authenticating;
const submit = useCallback((e: SyntheticEvent) => {
e.preventDefault();
dispatch(loginAsync(auth));
}, [auth, dispatch]);
const updateEmail = useCallback((e: FormEvent) => {
setAuth({
...auth,
email: e.currentTarget.value
});
}, [auth, setAuth]);
const updatePassword = useCallback((e: FormEvent) => {
setAuth({
...auth,
password: e.currentTarget.value
});
}, [auth, setAuth]);
return (
<form className="Login" onSubmit={submit}>
<label htmlFor="email">
Email
</label>
<input id="email" type="text" onChange={updateEmail}/>
<div className="separator hor" />
<label htmlFor="email">
Password
</label>
<input id="password" type="password" onChange={updatePassword} />
<div className="separator hor" />
<button disabled={disableSubmit(auth)} type="submit">Login</button>
</form>
);
};