«PoC» React+Redux the Extensible Way#
With this article I try to create an extensible version of a Web Application based on React and Redux. Completed
To do so I create first a small example with a few requirements. Those requirements shall be satisfied step by step according the release roadmap.
The sources are also available in the repository your-app-name. The commit history reflects rougly the release roadmap.
Requirements#
- Req_01:
The user shall be able to interact with the app by entering values and getting calculated values derived from their inputs.
- Req_02:
The app shall have a button. When the user presses this button a value is incremented. The button label shows that value. Initial value is 19.
- Req_03:
The app shall have a second button. When pressed it retrieves a value from an API call and adds it to its own value which is displayed in the button label. Initial state of the own variable is 76.
- Req_04:
The app shall display the sum of the values of ComponentA und ComponentB.
- Req_05:
The app shall provide a button to reset the variables to their initial ones.
Release Roadmap#
Releases and the Requirements to satisfy:
Rel_1 Rel_2 Rel_3 Rel_4
---|---------------|-----------|-----------|--------->
Req_01 Req_01 Req_01 Req_01
Req_02 Req_02 Req_02 Req_02
Req_03 Req_03 Req_03
Req_04 Req_04
Req_05
Architectural Design#
Concept#
The application is built using a modular architecture that allows for easy extension and maintenance. Each component manages its own state and reducers, which are dynamically registered to the store. This approach ensures that the application can scale easily as new features are added.
Building Block View#
We emphasize modularity and extensibility. Each component manages its own state and reducers, which are dynamically registered to the store. This allows the application to scale easily as new features are added.
Todo
Add description of interfaces between blocks
Runtime View#
Todo
Add runtime view
Sequence Diagrams for the “good” and “bad” scenarios
“Good” scenarios:
«event» Frontend (re)-load
«event» Backend start
«event» click on 1st button
«event» click on 2nd button
“Bad” scenarios:
«event» click on 2nd button while API is down
Scenario «event» Frontend (re)-load#
Todo
Finish Scenario «event» Frontend (re)-load
Design Alternatives Considered#
Single Store File: Initially, we considered managing all reducers in a single
store/index.js
file. However, this approach is not scalable as it requires modifying the store file every time a new reducer is added, leading to tight coupling and reduced maintainability.Manual Reducer Registration: Another approach was to manually register reducers in the
store/index.js
file. This was rejected because it hinders extensibility. Instead, we opted for a dynamic registration mechanism that allows each component to register its own reducer.Hardcoded Reducers: Hardcoding reducers in the main store configuration was also considered but discarded due to the lack of flexibility. Dynamic registration provides better modularity and allows for lazy loading of reducers.
Traceability Reqs to Architectural Elements#
Req_01 is satisfied by App, Store
Req_02 is satisfied by ComponentA
Req_03 is satisfied by ComponentB, Backend
Req_04 is satisfied by ComponentC
Req_05 is satisfied by ComponentD
Component Req01 Req02 Req03 Req04 Req05
=========================================================
Core ----------------------------------------------------
App X (x) (x) (X) (x)
Store X
Backend X
Extensions ----------------------------------------------
ComponentA X X
ComponentB X X
ComponentC X X
ComponentD X X
----------------------------------------------------------
| X : Component is responsible to satisfy requirement.
| (x) : Component needs minor modifications to satisfy requirement
Detailed Design & Construction#
Directory structure#
-- FRONTEND ---------------------------
/src
App.js
index.js
/store
reducerManager.js
store.js
/components
/ComponentA
index.js
slice.js
/ComponentB
index.js
slice.js
/ComponentC
index.js
slice.js
/ComponentD
index.js
slice.js
---------------------------------------
-- BACKEND ----------------------------
/backend
__init__.py
api.py
app.py
models.py
---------------------------------------
External Dependencies#
Create Skeleton of React Application (creates external dependencies):
npx create-react-app your-app-name
cd your-app-name
Install packages (adds additional external dependencies):
npm install \
@reduxjs/toolkit \
react-redux \
redux-thunk \
redux-logger \
«component» App#
1import React from 'react';
2import ReactDOM from 'react-dom/client';
3import App from './App';
4
5const root = ReactDOM.createRoot(document.getElementById('root'));
6root.render(
7 <React.StrictMode>
8 <App />
9 </React.StrictMode>
10);
1import React from 'react';
2import { Provider } from 'react-redux';
3import store from './store';
4
5import ComponentA from './components/ComponentA'
6import ComponentB from './components/ComponentB'
7import ComponentC from './components/ComponentC'
8import ComponentD from './components/ComponentD'
9
10const App = () => {
11 return (
12 <Provider store={store}>
13 <ComponentA/>
14 <ComponentB/>
15 <ComponentC/>
16 <ComponentD/>
17 </Provider>
18 );
19};
20
21export default App;
«component» Store#
1import { configureStore } from '@reduxjs/toolkit';
2import { thunk } from 'redux-thunk';
3import logger from 'redux-logger';
4import { createReducerManager } from './reducerManager';
5
6const registerReducer = (key, reducer) => {
7 store.reducerManager.add(key, reducer);
8 store.replaceReducer(store.reducerManager.reduce);
9 };
10
11export { registerReducer };
12
13const initialReducers = {};
14
15const reducerManager = createReducerManager(initialReducers);
16
17const store = configureStore({
18 reducer: reducerManager.reduce,
19 middleware: (getDefaultMiddleware) =>
20 getDefaultMiddleware().concat(thunk, logger),
21});
22
23store.reducerManager = reducerManager;
24
25export default store;
1import { combineReducers } from '@reduxjs/toolkit';
2
3export const createReducerManager = (initialReducers) => {
4 const reducers = { ...initialReducers };
5
6 let combinedReducer = combineReducers(reducers);
7
8 return {
9 getReducerMap: () => reducers,
10 reduce: (state, action) => combinedReducer(state, action),
11 add: (key, reducer) => {
12 if (!key || reducers[key]) {
13 return;
14 }
15
16 reducers[key] = reducer;
17 combinedReducer = combineReducers(reducers);
18 },
19 remove: (key) => {
20 if (!key || !reducers[key]) {
21 return;
22 }
23
24 delete reducers[key];
25 combinedReducer = combineReducers(reducers);
26 },
27 };
28};
Backend#
Create Skeleton of Python/FastAPI Application:
poetry init \
--name your-app-name \
--description "Backend of The Extensible App" \
--author "Calidus Callidus <calidus@callidus.it>" \
--python "^3.8"
--dependency fastapi \
--dependency uvicorn \
File backend/__init__.py
is empty.
«Component» App#
1from fastapi import FastAPI
2from fastapi.middleware.cors import CORSMiddleware
3from backend.api import router as api_router
4
5app = FastAPI()
6
7origins = [
8 "http://localhost:3000",
9 "http://127.0.0.1:3000",
10 # Add other origins as needed
11]
12
13app.add_middleware(
14 CORSMiddleware,
15 allow_origins=origins,
16 allow_credentials=True,
17 allow_methods=["*"],
18 allow_headers=["*"],
19)
20
21app.include_router(api_router)
22
23if __name__ == "__main__":
24 import uvicorn
25 uvicorn.run("backend.app:app", host="0.0.0.0", port=8000, reload=True)
«component» Models#
1from pydantic import BaseModel
2
3class ValueResponse(BaseModel):
4 value: int
«Component» API#
1from fastapi import APIRouter
2from backend.models import ValueResponse
3
4router = APIRouter()
5
6@router.get("/value", response_model=ValueResponse)
7async def get_value():
8 return ValueResponse(value=42)
Extensions#
Each extension manages its own state and reducers, which are dynamically registered to the store.
«component» ComponentA#
1import React from 'react';
2import { useSelector, useDispatch } from 'react-redux';
3import { incrementA } from './slice';
4
5const ComponentA = () => {
6 const valueA = useSelector(state => state.ComponentA.valueA);
7 const dispatch = useDispatch();
8
9 const incrementValueA = () => dispatch(incrementA());
10
11 return (
12 <button onClick={incrementValueA}>
13 Increment Value A: {valueA}
14 </button>
15 );
16};
17
18export default ComponentA;
1import { createSlice } from '@reduxjs/toolkit';
2import { registerReducer } from '../../store';
3
4const initialState = {
5 valueA: 19,
6};
7
8const componentASlice = createSlice({
9 name: 'ComponentA',
10 initialState,
11 reducers: {
12 incrementA(state) {
13 state.valueA += 1;
14 },
15 resetA(state) {
16 state.valueA = initialState.valueA;
17 },
18 },
19});
20
21export const { incrementA, resetA } = componentASlice.actions;
22
23// Register the reducer when this module is loaded
24registerReducer('ComponentA', componentASlice.reducer);
25
26export default componentASlice.reducer;
«component» ComponentB#
1import React from 'react';
2import { useSelector, useDispatch } from 'react-redux';
3import { incrementB } from './slice';
4
5const ComponentB = () => {
6 const valueB = useSelector(state => state.ComponentB.valueB);
7 const dispatch = useDispatch();
8
9 const fetchValueAndUpdateB = async () => {
10 const response = await fetch(`${window.location.origin.replace(/:\d+$/, '')}:8000/value`); // Use the current origin
11 const data = await response.json();
12 dispatch(incrementB(data.value));
13 };
14
15 return (
16 <button onClick={fetchValueAndUpdateB}>
17 Increment Value B: {valueB}
18 </button>
19 );
20};
21
22export default ComponentB;
1import { createSlice } from '@reduxjs/toolkit';
2import { registerReducer } from '../../store';
3
4const initialState = {
5 valueB: 76,
6};
7
8const componentBSlice = createSlice({
9 name: 'ComponentB',
10 initialState,
11 reducers: {
12 incrementB(state, action) {
13 state.valueB += action.payload;
14 },
15 resetB(state) {
16 state.valueB = initialState.valueB;
17 },
18 },
19});
20
21export const { incrementB, resetB } = componentBSlice.actions;
22
23// Register the reducer when this module is loaded
24registerReducer('ComponentB', componentBSlice.reducer);
25
26export default componentBSlice.reducer;
«component» ComponentC#
1import React from 'react';
2import { useSelector } from 'react-redux';
3
4const ComponentC = () => {
5 const valueA = useSelector(state => state.ComponentA.valueA);
6 const valueB = useSelector(state => state.ComponentB.valueB);
7
8 return (
9 <div>
10 Sum of Values: {valueA + valueB}
11 </div>
12 );
13};
14
15export default ComponentC;
1import { createSlice } from '@reduxjs/toolkit';
2import { registerReducer } from '../../store';
3
4const initialState = {};
5
6const componentCSlice = createSlice({
7 name: 'componentC',
8 initialState,
9 reducers: {},
10});
11
12registerReducer('componentC', componentCSlice.reducer);
13
14export default componentCSlice.reducer;
«component» ComponentD#
1import React from 'react';
2import { useDispatch } from 'react-redux';
3import { resetA } from '../ComponentA/slice';
4import { resetB } from '../ComponentB/slice';
5
6const ComponentD = () => {
7 const dispatch = useDispatch();
8
9 const resetValues = () => {
10 dispatch(resetA());
11 dispatch(resetB());
12 };
13
14 return (
15 <button onClick={resetValues}>
16 Reset Values
17 </button>
18 );
19};
20
21export default ComponentD;
1import { createSlice } from '@reduxjs/toolkit';
2import { registerReducer } from '../../store';
3
4const initialState = {};
5
6const componentDSlice = createSlice({
7 name: 'ComponentD',
8 initialState,
9 reducers: {},
10});
11
12registerReducer('componentD', componentDSlice.reducer);
13
14export default componentDSlice.reducer;
User Guide#
Application Start#
Start your React application:
# Start Backend
poetry run python -m backend.app
# Start Frontend
npm start
# Ready to go!
Usage#
Todo
Add short description on usage
Conclusion#
Todo
Add conclusion