Redux is a state-management library for single-page JavaScript applications, described in its official documentation as a predictable state container. The concepts are simple enough to absorb in a small demo, but moving from a counter app to a production-scale codebase exposes a significant gap. In the React ecosystem, Redux remains close to the default choice for applications that need state management, which makes a solid understanding of its mechanics worth having.
How Redux Constrains State Updates
Redux addresses state management by restricting how and when state may change. Those restrictions follow from three principles.
- A single source of truth. The application's entire
statelives in one Reduxstore, which can be pictured as a tree with a single ancestor. The store exposes methods for reading the current state and for subscribing to changes, and both are available from anywhere in the app. - State is read-only. The only path to a state change is dispatching the data as a plain object known as an action — effectively telling the state that there is data to insert, update or delete.
- Changes come from pure functions. A function that accepts the previous state and an action, then returns the next state object, is called a
reducer. Because a given set of inputs always produces the same output, the reducer is pure.
The third principle carries the most weight and is where Redux's behavior actually emerges. Reducers must avoid unpredictable code and side effects such as network requests, and they must not mutate the state object directly.
Why the Tooling Exists
Redux brings tradeoffs along with its benefits. To make writing it more efficient and more pleasant, the Redux team ships a toolkit that abstracts away store setup and bundles add-ons and utilities that simplify application code. One example: the library relies on Immer.js internally, which allows immutable update logic to be written in a "mutative" style.
Recommended reading: Better Reducers With Immer
The Application We'll Build
To work through these ideas concretely, this guide builds an application in which authenticated users create and manage digital diaries.
The walkthrough builds a React application called Diaries.app, with Mirage standing in for the backend, so no real server is needed. Source code lives in the diaries-app GitHub repository.
Bootstrapping the project and installing packages
A new React application is created with create-react-app via npx:
npx create-react-app diaries-app --template typescript
The TypeScript template is used so the code stays type-safe. After moving into the project directory
cd diaries-app
the dependencies are installed in three steps:
npm install --save redux react-redux @reduxjs/toolkit
npm install --save axios react-router-dom react-hook-form yup dayjs markdown-to-jsx sweetalert2
npm install --save-dev miragejs @types/react-redux @types/react-router-dom @types/yup @types/markdown-to-jsx
The first installs Redux, React-Redux — the official React bindings — and the Redux toolkit. The second adds packages used by the app but not required by Redux itself. The third pulls in Mirage plus type declarations for the packages installed as devDependencies.
Modelling the state with TypeScript interfaces
Authenticated users create or modify diaries; diaries are private by default and can be made public; entries are sorted by last modified date. That relationship looks like this:
Each resource — User, Diary and DiaryEntry — gets an interface, which in TypeScript describes the shape of an object. An interfaces directory is created under src:
cd src && mkdir interfaces
Inside it, the files are generated with:
touch entry.interface.ts
touch diary.interface.ts
touch user.interface.ts
This yields entry.interface.ts, diary.interface.ts and user.interface.ts. Keeping interfaces shared across the app in one place is a deliberate choice.
entry.interface.ts holds the Entry interface:
export interface Entry {
id?: string;
title: string;
content: string;
createdAt?: string;
updatedAt?: string;
diaryId?: string;
}
An entry carries a title, some content, and timestamps for creation and last update. The diaryId property is explained further below.
diary.interface.ts adds:
export interface Diary {
id?: string;
title: string;
type: 'private' | 'public';
createdAt?: string;
updatedAt?: string;
userId?: string;
entryIds: string[] | null;
}
The type property accepts exactly ‘private’ or ‘public’ — any other value is a compiler error.
user.interface.ts describes the User object:
export interface User {
id?: string;
username: string;
email: string;
password?: string;
diaryIds: string[] | null;
}
Mocking the backend with MirageJS
Mirage setup details are out of scope here; the linked series covers them. In src, a file named server.ts is created:
mkdir -p services/mirage
cd services/mirage
# ~/diaries-app/src/services/mirage
touch server.ts
Its contents:
import { Server, Model, Factory, belongsTo, hasMany, Response } from 'miragejs';
export const handleErrors = (error: any, message = 'An error ocurred') => {
return new Response(400, undefined, {
data: {
message,
isError: true,
},
});
};
export const setupServer = (env?: string): Server => {
return new Server({
environment: env ?? 'development',
models: {
entry: Model.extend({
diary: belongsTo(),
}),
diary: Model.extend({
entry: hasMany(),
user: belongsTo(),
}),
user: Model.extend({
diary: hasMany(),
}),
},
factories: {
user: Factory.extend({
username: 'test',
password: 'password',
email: '[email protected]',
}),
},
seeds: (server): any => {
server.create('user');
},
routes(): void {
this.urlPrefix = 'https://diaries.app';
},
});
};
Two functions are exported: an error-handling utility and setupServer(), which returns a new server instance. The optional argument to setupServer() switches the server’s environment, which is useful for configuring Mirage for testing later.
The server’s models property declares User, Diary and Entry. The diaryId added earlier to the Entry interface is filled in automatically with the id of the diary the entry is saved to, which is how Mirage relates an Entry to a Diary; likewise, a new diary’s userId is set to that user’s id. The database is seeded with a default user, and Mirage is configured to intercept every request starting with https://diaries.app. No route handlers exist yet, so the next step is to add them.
From src/services/mirage, a routes directory is created:
# ~/diaries-app/src/services/mirage
mkdir routes
Inside it, user.ts is created
cd routes
touch user.ts
and filled with:
import { Response, Request } from 'miragejs';
import { handleErrors } from '../server';
import { User } from '../../../interfaces/user.interface';
import { randomBytes } from 'crypto';
const generateToken = () => randomBytes(8).toString('hex');
export interface AuthResponse {
token: string;
user: User;
}
const login = (schema: any, req: Request): AuthResponse | Response => {
const { username, password } = JSON.parse(req.requestBody);
const user = schema.users.findBy({ username });
if (!user) {
return handleErrors(null, 'No user with that username exists');
}
if (password !== user.password) {
return handleErrors(null, 'Password is incorrect');
}
const token = generateToken();
return {
user: user.attrs as User,
token,
};
};
const signup = (schema: any, req: Request): AuthResponse | Response => {
const data = JSON.parse(req.requestBody);
const exUser = schema.users.findBy({ username: data.username });
if (exUser) {
return handleErrors(null, 'A user with that username already exists.');
}
const user = schema.users.create(data);
const token = generateToken();
return {
user: user.attrs as User,
token,
};
};
export default {
login,
signup,
};
The login and signup methods receive a Schema class and a fake Request object. After validating the password or checking that the login does not already exist, they return the existing user or a newly created user. The Schema object talks to Mirage’s ORM; the Request object carries the intercepted request’s body and headers.
Diary and entry handling goes in diary.ts within the same directory:
touch diary.ts
The Diary methods:
export const create = (
schema: any,
req: Request
): { user: User; diary: Diary } | Response => {
try {
const { title, type, userId } = JSON.parse(req.requestBody) as Partial<
Diary
>;
const exUser = schema.users.findBy({ id: userId });
if (!exUser) {
return handleErrors(null, 'No such user exists.');
}
const now = dayjs().format();
const diary = exUser.createDiary({
title,
type,
createdAt: now,
updatedAt: now,
});
return {
user: {
...exUser.attrs,
},
diary: diary.attrs,
};
} catch (error) {
return handleErrors(error, 'Failed to create Diary.');
}
};
export const updateDiary = (schema: any, req: Request): Diary | Response => {
try {
const diary = schema.diaries.find(req.params.id);
const data = JSON.parse(req.requestBody) as Partial<Diary>;
const now = dayjs().format();
diary.update({
...data,
updatedAt: now,
});
return diary.attrs as Diary;
} catch (error) {
return handleErrors(error, 'Failed to update Diary.');
}
};
export const getDiaries = (schema: any, req: Request): Diary[] | Response => {
try {
const user = schema.users.find(req.params.id);
return user.diary as Diary[];
} catch (error) {
return handleErrors(error, 'Could not get user diaries.');
}
};
The entry methods:
export const addEntry = (
schema: any,
req: Request
): { diary: Diary; entry: Entry } | Response => {
try {
const diary = schema.diaries.find(req.params.id);
const { title, content } = JSON.parse(req.requestBody) as Partial<Entry>;
const now = dayjs().format();
const entry = diary.createEntry({
title,
content,
createdAt: now,
updatedAt: now,
});
diary.update({
...diary.attrs,
updatedAt: now,
});
return {
diary: diary.attrs,
entry: entry.attrs,
};
} catch (error) {
return handleErrors(error, 'Failed to save entry.');
}
};
export const getEntries = (
schema: any,
req: Request
): { entries: Entry[] } | Response => {
try {
const diary = schema.diaries.find(req.params.id);
return diary.entry;
} catch (error) {
return handleErrors(error, 'Failed to get Diary entries.');
}
};
export const updateEntry = (schema: any, req: Request): Entry | Response => {
try {
const entry = schema.entries.find(req.params.id);
const data = JSON.parse(req.requestBody) as Partial<Entry>;
const now = dayjs().format();
entry.update({
...data,
updatedAt: now,
});
return entry.attrs as Entry;
} catch (error) {
return handleErrors(error, 'Failed to update entry.');
}
};
and the imports at the top of the file:
import { Response, Request } from 'miragejs';
import { handleErrors } from '../server';
import { Diary } from '../../../interfaces/diary.interface';
import { Entry } from '../../../interfaces/entry.interface';
import dayjs from 'dayjs';
import { User } from '../../../interfaces/user.interface';
The file exports handlers for the Diary and Entry models. create calls user.createDiary() to save a new diary tied to a user account. addEntry creates a new entry and associates it with a diary; updateEntry updates an existing entry’s data and sets its updatedAt property to the current timestamp. updateDiary also stamps the diary with the time of the change — that property is what records will later be sorted by. getDiaries retrieves a user’s diaries, and getEntries retrieves the entries of a selected diary.
With those handlers defined, server.ts imports them:
import { Server, Model, Factory, belongsTo, hasMany, Response } from 'miragejs';
import user from './routes/user';
import * as diary from './routes/diary';
and registers them on the server’s route property:
export const setupServer = (env?: string): Server => {
return new Server({
// ...
routes(): void {
this.urlPrefix = 'https://diaries.app';
this.get('/diaries/entries/:id', diary.getEntries);
this.get('/diaries/:id', diary.getDiaries);
this.post('/auth/login', user.login);
this.post('/auth/signup', user.signup);
this.post('/diaries/', diary.create);
this.post('/diaries/entry/:id', diary.addEntry);
this.put('/diaries/entry/:id', diary.updateEntry);
this.put('/diaries/:id', diary.updateDiary);
},
});
};
Any network request from the app matching a route handler is now intercepted and dispatched to the corresponding function.
The app itself is made server-aware in src/index.tsx by importing setupServer():
import { setupServer } from './services/mirage/server';
and calling it just before ReactDOM.render():
if (process.env.NODE_ENV === 'development') {
setupServer();
}
The surrounding check keeps the Mirage server running in development mode only.
One more piece of plumbing comes before Redux: a custom Axios instance that cuts down on repeated code later. A file named api.ts under src/services holds:
import axios, { AxiosInstance, AxiosResponse, AxiosError } from 'axios';
import { showAlert } from '../util';
const http: AxiosInstance = axios.create({
baseURL: 'https://diaries.app',
});
http.defaults.headers.post['Content-Type'] = 'application/json';
http.interceptors.response.use(
async (response: AxiosResponse): Promise => {
if (response.status >= 200 && response.status < 300) {
return response.data;
}
},
(error: AxiosError) => {
const { response, request }: {
response?: AxiosResponse;
request?: XMLHttpRequest;
} = error;
if (response) {
if (response.status >= 400 && response.status < 500) {
showAlert(response.data?.data?.message, 'error');
return null;
}
} else if (request) {
showAlert('Request failed. Please try again.', 'error');
return null;
}
return Promise.reject(error);
}
);
export default http;
This exports an Axios instance whose base is the app’s API URL, https://diaries.app, plus an interceptor for success and error responses. Errors are surfaced through a sweetalert toast, configured next in util.ts at the root of src:
import Swal, { SweetAlertIcon } from 'sweetalert2';
export const showAlert = (titleText = 'Something happened.', alertType?: SweetAlertIcon): void => {
Swal.fire({
titleText,
position: 'top-end',
timer: 3000,
timerProgressBar: true,
toast: true,
showConfirmButton: false,
showCancelButton: true,
cancelButtonText: 'Dismiss',
icon: alertType,
showClass: {
popup: 'swal2-noanimation',
backdrop: 'swal2-noanimation',
},
hideClass: {
popup: '',
backdrop: '',
},
});
};
The exported function displays a toast and takes parameters for the message and its type. The Axios error interceptor uses it like this:
showAlert(response.data?.data?.message, 'error');
Development-mode requests now reach Mirage instead of a real server.
Building the store with Redux toolkit
The store relies on three Redux toolkit exports: configureStore(), getDefaultMiddleware() and createSlice().
configureStore() abstracts over Redux’s createStore(), which it uses internally, and wires up useful development tools:
export const store = configureStore({
reducer: rootReducer, // a single reducer function or an object of slice reducers
});
createSlice() simplifies creating action creators and slice reducers. It takes an initial state, an object full of reducer functions and a slice name, then generates matching action creators and action types and returns a single reducer function usable as a slice reducer with combineReducers(). Because state is one tree managed by one root reducer, splitting that root reducer into slices — each supplying an initial value and computing updates to its part of the state — is recommended for maintainability, and combineReducers() joins them back together.
The store accepts further options, including custom middleware arrays and a preloadedState for starting from saved state. Supplying the middleware option means listing all middleware to add; getDefaultMiddleware() returns the defaults to keep:
export const store = configureStore({
// ...
middleware: [...getDefaultMiddleware(), customMiddleware],
});
Files are organized ducks-style, following the ducks modular Redux pattern and the GitHub Issues sample app, so related components, actions and reducers share a directory. The resulting state object:
type RootState = {
auth: {
token: string | null;
isAuthenticated: boolean;
};
diaries: Diary[];
entries: Entry[];
user: User | null;
editor: {
canEdit: boolean;
currentlyEditing: Entry | null;
activeDiaryId: string | null;
};
}
A features directory is created under src:
# ~/diaries-app/src
mkdir features
with auth, diary and entry subdirectories:
cd features
mkdir auth diary entry
In auth, a file named authSlice.ts is created:
cd auth
# ~/diaries-app/src/features/auth
touch authSlice.ts
and given the following contents:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface AuthState {
token: string | null;
isAuthenticated: boolean;
}
const initialState: AuthState = {
token: null,
isAuthenticated: false,
};
const auth = createSlice({
name: 'auth',
initialState,
reducers: {
saveToken(state, { payload }: PayloadAction) {
if (payload) {
state.token = payload;
}
},
clearToken(state) {
state.token = null;
},
setAuthState(state, { payload }: PayloadAction) {
state.isAuthenticated = payload;
},
},
});
export const { saveToken, clearToken, setAuthState } = auth.actions;
export default auth.reducer;
This slice governs the auth property. Its reducers map holds the reducer functions for updating values in that slice, and the call returns generated action creators plus a single slice reducer. Following the ducks pattern, the action creators are named exports and the reducer function is the default export.
Still in the auth directory, userSlice.ts holds:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { User } from '../../interfaces/user.interface';
const user = createSlice({
name: 'user',
initialState: null as User | null,
reducers: {
setUser(state, { payload }: PayloadAction<User | null>) {
return state = (payload != null) ? payload : null;
},
},
});
export const { setUser } = user.actions;
export default user.reducer;
This slice reducer covers the user property of the store. setUser takes a payload of user data and writes it into the state; with no data, the user property is set to null.
Under src/features/diary, diariesSlice.ts is created:
# ~/diaries-app/src/features
cd diary
touch diariesSlice.ts
with the following code:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Diary } from '../../interfaces/diary.interface';
const diaries = createSlice({
name: 'diaries',
initialState: [] as Diary[],
reducers: {
addDiary(state, { payload }: PayloadAction<Diary[]>) {
const diariesToSave = payload.filter((diary) => {
return state.findIndex((item) => item.id === diary.id) === -1;
});
state.push(...diariesToSave);
},
updateDiary(state, { payload }: PayloadAction<Diary>) {
const { id } = payload;
const diaryIndex = state.findIndex((diary) => diary.id === id);
if (diaryIndex !== -1) {
state.splice(diaryIndex, 1, payload);
}
},
},
});
export const { addDiary, updateDiary } = diaries.actions;
export default diaries.reducer;
The diaries property is the array of the user’s diaries, so these reducers operate on the received state through array methods. The code written is normal “mutative” code, which works because reducers from createSlice() are wrapped in Immer’s produce() method, so Immer returns a correctly, immutably updated result anyway.
Another entriesSlice.ts file is created under src/features/entry:
# ~/diaries-app/src/features
mkdir entry
cd entry
touch entriesSlice.ts
with this code:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Entry } from '../../interfaces/entry.interface';
const entries = createSlice({
name: 'entries',
initialState: [] as Entry[],
reducers: {
setEntries(state, { payload }: PayloadAction<Entry[] | null>) {
return (state = payload != null ? payload : []);
},
updateEntry(state, { payload }: PayloadAction<Entry>) {
const { id } = payload;
const index = state.findIndex((e) => e.id === id);
if (index !== -1) {
state.splice(index, 1, payload);
}
},
},
});
export const { setEntries, updateEntry } = entries.actions;
export default entries.reducer;
Its reducers mirror the previous slice’s logic. The entries property is also an array, but it holds entries for one diary only — the diary currently in focus.
A final slice, editorSlice.ts in src/features/entry, covers the editor property:
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Entry } from '../../interfaces/entry.interface';
interface EditorState {
canEdit: boolean;
currentlyEditing: Entry | null;
activeDiaryId: string | null;
}
const initialState: EditorState = {
canEdit: false,
currentlyEditing: null,
activeDiaryId: null,
};
const editor = createSlice({
name: 'editor',
initialState,
reducers: {
setCanEdit(state, { payload }: PayloadAction<boolean>) {
state.canEdit = payload != null ? payload : !state.canEdit;
},
setCurrentlyEditing(state, { payload }: PayloadAction<Entry | null>) {
state.currentlyEditing = payload;
},
setActiveDiaryId(state, { payload }: PayloadAction<string>) {
state.activeDiaryId = payload;
},
},
});
export const { setCanEdit, setCurrentlyEditing, setActiveDiaryId } = editor.actions;
export default editor.reducer;
Its properties let the app tell whether the user wants to switch to editing mode, which diary the edited entry belongs to, and which entry is being edited.
The slices come together in rootReducer.ts in the src directory:
import { combineReducers } from '@reduxjs/toolkit';
import authReducer from './features/auth/authSlice';
import userReducer from './features/auth/userSlice';
import diariesReducer from './features/diary/diariesSlice';
import entriesReducer from './features/entry/entriesSlice';
import editorReducer from './features/entry/editorSlice';
const rootReducer = combineReducers({
auth: authReducer,
diaries: diariesReducer,
entries: entriesReducer,
user: userReducer,
editor: editorReducer,
});
export type RootState = ReturnType<typeof rootReducer>;
export default rootReducer;
combineReducers() joins the slice reducers into a single root reducer, and the file also exports the RootState type for selecting values from the store later. That default export can now build the store in store.ts:
import { configureStore } from '@reduxjs/toolkit';
import rootReducer from './rootReducer';
import { useDispatch } from 'react-redux';
const store = configureStore({
reducer: rootReducer,
});
type AppDispatch = typeof store.dispatch;
export const useAppDispatch = () => useDispatch<AppDispatch>();
export default store;
configureStore() from Redux toolkit creates the store, and a useAppDispatch() hook is exported that simply returns a typed useDispatch() hook.
Finally, index.tsx gets its imports updated:
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './app/App';
import * as serviceWorker from './serviceWorker';
import { setupServer } from './services/mirage/server';
import { Provider } from 'react-redux';
import store from './store';
// ...
and the store is handed to the component tree by wrapping the top-level <App /> in <Provider />:
ReactDOM.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>,
document.getElementById('root')
);
Starting the app and opening localhost:3000 with the Redux Dev Tools extension enabled shows the state:
Wiring Components To The Store
The remainder of the build connects React components to the store created earlier: dispatching actions, reading state with selectors, and writing reducer-driven updates. The finished app looks like this.
Authentication And The App Shell
Move App.tsx and its related files out of src into their own directory:
# ~/diaries-app/src
mkdir app
mv App.tsx App.test.tsx app
App.css and logo.svg can be deleted.
Replacing the contents of App.tsx gives us the shell:
import React, { FC, lazy, Suspense } from 'react';
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { RootState } from '../rootReducer';
const Auth = lazy(() => import('../features/auth/Auth'));
const Home = lazy(() => import('../features/home/Home'));
const App: FC = () => {
const isLoggedIn = useSelector(
(state: RootState) => state.auth.isAuthenticated
);
return (
<Router>
<Switch>
<Route path="/">
<Suspense fallback={<p>Loading...</p>}>
{isLoggedIn ? <Home /> : <Auth />}
</Suspense>
</Route>
</Switch>
</Router>
);
};
export default App;
Unauthenticated users get <Auth />; everyone else gets <Home />. Neither exists yet, so create Auth.tsx under src/features/auth:
import React, { FC, useState } from 'react';
import { useForm } from 'react-hook-form';
import { User } from '../../interfaces/user.interface';
import * as Yup from 'yup';
import http from '../../services/api';
import { saveToken, setAuthState } from './authSlice';
import { setUser } from './userSlice';
import { AuthResponse } from '../../services/mirage/routes/user';
import { useAppDispatch } from '../../store';
const schema = Yup.object().shape({
username: Yup.string()
.required('What? No username?')
.max(16, 'Username cannot be longer than 16 characters'),
password: Yup.string().required('Without a password, "None shall pass!"'),
email: Yup.string().email('Please provide a valid email address ([email protected])'),
});
const Auth: FC = () => {
const { handleSubmit, register, errors } = useForm<User>({
validationSchema: schema,
});
const [isLogin, setIsLogin] = useState(true);
const [loading, setLoading] = useState(false);
const dispatch = useAppDispatch();
const submitForm = (data: User) => {
const path = isLogin ? '/auth/login' : '/auth/signup';
http
.post<User, AuthResponse>(path, data)
.then((res) => {
if (res) {
const { user, token } = res;
dispatch(saveToken(token));
dispatch(setUser(user));
dispatch(setAuthState(true));
}
})
.catch((error) => {
console.log(error);
})
.finally(() => {
setLoading(false);
});
};
return (
<div className="auth">
<div className="card">
<form onSubmit={handleSubmit(submitForm)}>
<div className="inputWrapper">
<input ref={register} name="username" placeholder="Username" />
{errors && errors.username && (
<p className="error">{errors.username.message}</p>
)}
</div>
<div className="inputWrapper">
<input
ref={register}
name="password"
type="password"
placeholder="Password"
/>
{errors && errors.password && (
<p className="error">{errors.password.message}</p>
)}
</div>
{!isLogin && (
<div className="inputWrapper">
<input
ref={register}
name="email"
placeholder="Email (optional)"
/>
{errors && errors.email && (
<p className="error">{errors.email.message}</p>
)}
</div>
)}
<div className="inputWrapper">
<button type="submit" disabled={loading}>
{isLogin ? 'Login' : 'Create account'}
</button>
</div>
<p
onClick={() => setIsLogin(!isLogin)}
style={{ cursor: 'pointer', opacity: 0.7 }}
>
{isLogin ? 'No account? Create one' : 'Already have an account?'}
</p>
</form>
</div>
</div>
);
};
export default Auth;
The component hosts a login/create-account form. Field validation runs through Yup, and a successful authentication calls useAppDispatch to fire the relevant actions. Dispatched actions and resulting state changes are visible in the Redux DevTools Extension:
Then create Home.tsx under src/features/home:
import React, { FC } from 'react';
const Home: FC = () => {
return (
<div>
<p>Welcome user!</p>
</div>
);
};
export default Home;
For now it only shows some text for the authenticated user; it will grow as the rest of the app is built.
The Editor Feature
Next up is the editor, which uses the previously installed markdown-to-jsx library for markdown rendering. Create Editor.tsx in src/features/entry:
import React, { FC, useState, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../../rootReducer';
import Markdown from 'markdown-to-jsx';
import http from '../../services/api';
import { Entry } from '../../interfaces/entry.interface';
import { Diary } from '../../interfaces/diary.interface';
import { setCurrentlyEditing, setCanEdit } from './editorSlice';
import { updateDiary } from '../diary/diariesSlice';
import { updateEntry } from './entriesSlice';
import { showAlert } from '../../util';
import { useAppDispatch } from '../../store';
const Editor: FC = () => {
const { currentlyEditing: entry, canEdit, activeDiaryId } = useSelector(
(state: RootState) => state.editor
);
const [editedEntry, updateEditedEntry] = useState(entry);
const dispatch = useAppDispatch();
const saveEntry = async () => {
if (activeDiaryId == null) {
return showAlert('Please select a diary.', 'warning');
}
if (entry == null) {
http
.post<Entry, { diary: Diary; entry: Entry }>(
`/diaries/entry/${activeDiaryId}`,
editedEntry
)
.then((data) => {
if (data != null) {
const { diary, entry: _entry } = data;
dispatch(setCurrentlyEditing(_entry));
dispatch(updateDiary(diary));
}
});
} else {
http
.put<Entry, Entry>(`diaries/entry/${entry.id}`, editedEntry)
.then((_entry) => {
if (_entry != null) {
dispatch(setCurrentlyEditing(_entry));
dispatch(updateEntry(_entry));
}
});
}
dispatch(setCanEdit(false));
};
useEffect(() => {
updateEditedEntry(entry);
}, [entry]);
return (
<div className="editor">
<header
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
marginBottom: '0.2em',
paddingBottom: '0.2em',
borderBottom: '1px solid rgba(0,0,0,0.1)',
}}
>
{entry && !canEdit ? (
<h4>
{entry.title}
<a
href="#edit"
onClick={(e) => {
e.preventDefault();
if (entry != null) {
dispatch(setCanEdit(true));
}
}}
style={{ marginLeft: '0.4em' }}
>
(Edit)
</a>
</h4>
) : (
<input
value={editedEntry?.title ?? ''}
disabled={!canEdit}
onChange={(e) => {
if (editedEntry) {
updateEditedEntry({
...editedEntry,
title: e.target.value,
});
} else {
updateEditedEntry({
title: e.target.value,
content: '',
});
}
}}
/>
)}
</header>
{entry && !canEdit ? (
<Markdown>{entry.content}</Markdown>
) : (
<>
<textarea
disabled={!canEdit}
placeholder="Supports markdown!"
value={editedEntry?.content ?? ''}
onChange={(e) => {
if (editedEntry) {
updateEditedEntry({
...editedEntry,
content: e.target.value,
});
} else {
updateEditedEntry({
title: '',
content: e.target.value,
});
}
}}
/>
<button onClick={saveEntry} disabled={!canEdit}>
Save
</button>
</>
)}
</div>
);
};
export default Editor;
Walking through it: values with correctly inferred types are selected from app state via useSelector() from react-redux. A stateful value, editedEntry, is initialized from the selected editor.currentlyEditing property. The saveEntry function updates or creates an entry in the API and dispatches the corresponding Redux action. A useEffect fires when editor.currentlyEditing changes, and the returned UI reacts to state — rendering the entry content as JSX elements while the user is not editing, for example.
That completes the Entry feature. What remains is finishing Diary and importing the main components into Home.
Diary Components
Create DiaryTile.tsx in src/features/diary. It presents a diary and its entries and lets the user edit the diary title:
import React, { FC, useState } from 'react';
import { Diary } from '../../interfaces/diary.interface';
import http from '../../services/api';
import { updateDiary } from './diariesSlice';
import { setCanEdit, setActiveDiaryId, setCurrentlyEditing } from '../entry/editorSlice';
import { showAlert } from '../../util';
import { Link } from 'react-router-dom';
import { useAppDispatch } from '../../store';
interface Props {
diary: Diary;
}
const buttonStyle: React.CSSProperties = {
fontSize: '0.7em',
margin: '0 0.5em',
};
const DiaryTile: FC<Props> = (props) => {
const [diary, setDiary] = useState(props.diary);
const [isEditing, setIsEditing] = useState(false);
const dispatch = useAppDispatch();
const totalEntries = props.diary?.entryIds?.length;
const saveChanges = () => {
http
.put<Diary, Diary>(`/diaries/${diary.id}`, diary)
.then((diary) => {
if (diary) {
dispatch(updateDiary(diary));
showAlert('Saved!', 'success');
}
})
.finally(() => {
setIsEditing(false);
});
};
return (
<div className="diary-tile">
<h2
className="title"
title="Click to edit"
onClick={() => setIsEditing(true)}
style={{
cursor: 'pointer',
}}
>
{isEditing ? (
<input
value={diary.title}
onChange={(e) => {
setDiary({
...diary,
title: e.target.value,
});
}}
onKeyUp={(e) => {
if (e.key === 'Enter') {
saveChanges();
}
}}
/>
) : (
<span>{diary.title}</span>
)}
</h2>
<p className="subtitle">{totalEntries ?? '0'} saved entries</p>
<div style={{ display: 'flex' }}>
<button
style={buttonStyle}
onClick={() => {
dispatch(setCanEdit(true));
dispatch(setActiveDiaryId(diary.id as string));
dispatch(setCurrentlyEditing(null));
}}
>
Add New Entry
</button>
<Link to={`diary/${diary.id}`} style={{ width: '100%' }}>
<button className="secondary" style={buttonStyle}>
View all →
</button>
</Link>
</div>
</div>
);
};
export default DiaryTile;
The diary object arrives as a prop and renders from local state plus that prop. Not all state belongs in Redux — sharing data through props and keeping local component state is acceptable and, in some cases, encouraged.
Next, DiaryEntriesList.tsx in the same directory lists a diary's entries with the most recently updated first:
import React, { FC, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useSelector } from 'react-redux';
import { RootState } from '../../rootReducer';
import http from '../../services/api';
import { Entry } from '../../interfaces/entry.interface';
import { setEntries } from '../entry/entriesSlice';
import { setCurrentlyEditing, setCanEdit } from '../entry/editorSlice';
import dayjs from 'dayjs';
import { useAppDispatch } from '../../store';
const DiaryEntriesList: FC = () => {
const { entries } = useSelector((state: RootState) => state);
const dispatch = useAppDispatch();
const { id } = useParams();
useEffect(() => {
if (id != null) {
http
.get<null, { entries: Entry[] }>(`/diaries/entries/${id}`)
.then(({ entries: _entries }) => {
if (_entries) {
const sortByLastUpdated = _entries.sort((a, b) => {
return dayjs(b.updatedAt).unix() - dayjs(a.updatedAt).unix();
});
dispatch(setEntries(sortByLastUpdated));
}
});
}
}, [id, dispatch]);
return (
<div className="entries">
<header>
<Link to="/">
<h3>← Go Back</h3>
</Link>
</header>
<ul>
{entries.map((entry) => (
<li
key={entry.id}
onClick={() => {
dispatch(setCurrentlyEditing(entry));
dispatch(setCanEdit(true));
}}
>
{entry.title}
</li>
))}
</ul>
</div>
);
};
export default DiaryEntriesList;
This component subscribes to the state's entries property, and its fetching effect runs only when id changes; the value comes from the URL as a path parameter via the useParams() hook from react-router. The next file, Diaries.tsx, lets users create and view diaries and renders a diary's entries when it is in focus:
import React, { FC, useEffect } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../../rootReducer';
import http from '../../services/api';
import { Diary } from '../../interfaces/diary.interface';
import { addDiary } from './diariesSlice';
import Swal from 'sweetalert2';
import { setUser } from '../auth/userSlice';
import DiaryTile from './DiaryTile';
import { User } from '../../interfaces/user.interface';
import { Route, Switch } from 'react-router-dom';
import DiaryEntriesList from './DiaryEntriesList';
import { useAppDispatch } from '../../store';
import dayjs from 'dayjs';
const Diaries: FC = () => {
const dispatch = useAppDispatch();
const diaries = useSelector((state: RootState) => state.diaries);
const user = useSelector((state: RootState) => state.user);
useEffect(() => {
const fetchDiaries = async () => {
if (user) {
http.get<null, Diary[]>(`diaries/${user.id}`).then((data) => {
if (data && data.length > 0) {
const sortedByUpdatedAt = data.sort((a, b) => {
return dayjs(b.updatedAt).unix() - dayjs(a.updatedAt).unix();
});
dispatch(addDiary(sortedByUpdatedAt));
}
});
}
};
fetchDiaries();
}, [dispatch, user]);
const createDiary = async () => {
const result = await Swal.mixin({
input: 'text',
confirmButtonText: 'Next →',
showCancelButton: true,
progressSteps: ['1', '2'],
}).queue([
{
titleText: 'Diary title',
input: 'text',
},
{
titleText: 'Private or public diary?',
input: 'radio',
inputOptions: {
private: 'Private',
public: 'Public',
},
inputValue: 'private',
},
]);
if (result.value) {
const { value } = result;
const {
diary,
user: _user,
} = await http.post<Partial<Diary>, { diary: Diary; user: User }>('/diaries/', {
title: value[0],
type: value[1],
userId: user?.id,
});
if (diary && user) {
dispatch(addDiary([diary] as Diary[]));
dispatch(addDiary([diary] as Diary[]));
dispatch(setUser(_user));
return Swal.fire({
titleText: 'All done!',
confirmButtonText: 'OK!',
});
}
}
Swal.fire({
titleText: 'Cancelled',
});
};
return (
<div style={{ padding: '1em 0.4em' }}>
<Switch>
<Route path="/diary/:id">
<DiaryEntriesList />
</Route>
<Route path="/">
<button onClick={createDiary}>Create New</button>
{diaries.map((diary, idx) => (
<DiaryTile key={idx} diary={diary} />
))}
</Route>
</Switch>
</div>
);
};
export default Diaries;
A function inside a useEffect hook fetches the user's diaries, and another creates a new diary. Diaries render inside react-router's <Route />: entries appear when a diary's id matches the path parameter in /diary/:id, otherwise the list of the user's diaries is shown.
Finishing Home And Styling
Update the imports in Home.tsx:
import React, { FC } from 'react';
import Diaries from '../diary/Diaries';
import Editor from '../entry/Editor';
Then rewrite the component's return statement:
return (
<div className="two-cols">
<div className="left">
<Diaries />
</div>
<div className="right">
<Editor />
</div>
</div>
Finally, replace the contents of index.css in the app's src directory:
:root {
--primary-color: #778899;
--error-color: #f85032;
--text-color: #0d0d0d;
--transition: all ease-in-out 0.3s;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html, body, #root {
height: 100%;
}
*, *:before, *:after {
box-sizing: border-box;
}
.auth {
display: flex;
align-items: center;
height: 100%;
}
.card {
background: #fff;
padding: 3rem;
text-align: center;
box-shadow: 2px 8px 12px rgba(0, 0, 0, 0.1);
max-width: 450px;
width: 90%;
margin: 0 auto;
}
.inputWrapper {
margin: 1rem auto;
width: 100%;
}
input:not([type='checkbox']), button {
border-radius: 0.5rem;
width: 100%;
}
input:not([type='checkbox']), textarea {
border: 2px solid rgba(0, 0, 0, 0.1);
padding: 1em;
color: var(--text-color);
transition: var(--transition);
}
input:not([type='checkbox']):focus, textarea:focus {
outline: none;
border-color: var(--primary-color);
}
button {
appearance: none;
border: 1px solid var(--primary-color);
color: #fff;
background-color: var(--primary-color);
text-transform: uppercase;
font-weight: bold;
outline: none;
cursor: pointer;
padding: 1em;
box-shadow: 1px 4px 6px rgba(0, 0, 0, 0.1);
transition: var(--transition);
}
button.secondary {
color: var(--primary-color);
background-color: #fff;
border-color: #fff;
}
button:hover, button:focus {
box-shadow: 1px 6px 8px rgba(0, 0, 0, 0.1);
}
.error {
margin: 0;
margin-top: 0.2em;
font-size: 0.8em;
color: var(--error-color);
animation: 0.3s ease-in-out forwards fadeIn;
}
.two-cols {
display: flex;
flex-wrap: wrap;
height: 100vh;
}
.two-cols .left {
border-right: 1px solid rgba(0, 0, 0, 0.1);
height: 100%;
overflow-y: scroll;
}
.two-cols .right {
overflow-y: auto;
}
.title {
font-size: 1.3rem;
}
.subtitle {
font-size: 0.9rem;
opacity: 0.85;
}
.title, .subtitle {
margin: 0;
}
.diary-tile {
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
padding: 1em;
}
.editor {
height: 100%;
padding: 1em;
}
.editor input {
width: 100%;
}
.editor textarea {
width: 100%;
height: calc(100vh - 160px);
}
.entries ul {
list-style: none;
padding: 0;
}
.entries li {
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding: 0.5em;
cursor: pointer;
}
.entries li:nth-child(even) {
background: rgba(0, 0, 0, 0.1);
}
@media (min-width: 768px) {
.two-cols .left {
width: 25%;
}
.two-cols .right {
width: 75%;
}
}
@keyframes fadeIn {
0% {
opacity: 0;
}
100% {
opacity: 0.8;
}
}
Run npm start or yarn start to view the final app at https://localhost:3000.
Where To Go From Here
The patterns covered — a typed store, slices, thunks, selectors and hooked dispatch — aim at applications that stay debuggable and easy to extend. This is not an exhaustive treatment; discussions around Redux and its concepts continue (Redux FAQs), and the Redux and React-Redux docs go deeper.
- See source code (GitHub repo)
Array.prototype.reduce()on MDN Docs- Immer.js Docs
- Mirage.js Deep Dive Series
- Axios on npm
- The "Ducks" Proposal
- The Growing Need For Effective Password Management
- A Guide To Redux Toolkit With TypeScript
- Reactive Variables In GraphQL Apollo Client
- Converting Plain Text To Encoded HTML With Vanilla JavaScript




