This tutorial series demonstrates an easy way to manage normalized relational React state using a library I made called Normalized Reducer.
Primer on relationality and normalization
For a loose definition, data is “relational” when it contains associations that can be described as has-one, has-many; e.g. an author has many posts, a post has one author. Relational data is “normalized” when each entity has a single source of truth and references its related data through identifiers.
Suppose an app has posts and tags, where a post can have many tags, and a tag can be attached to multiple posts.
A denormalized representation would be:
const posts = [ { id: 'p1', title: 'post 1', tags: [ { id: 't1', value: 'tag 1' }, { id: 't2', value: 'tag 2' } ] }, { id: 'p2', title: 'post 2', tags: [ { id: 't2', value: 'tag 2' }, { id: 't3', value: 'tag 3' } ] }, ]
Notice:
- data duplication. The data of tag
t2
, which is associated with two posts, is duplicated across two places.
- relationships are represented by nesting entities within one another
A normalized representation would be:
const data = { posts: { 'p1': { id: 'p1', title: 'post 1', tagIds: ['t1', 't2'] }, 'p2': { id: 'p2', title: 'post 2', tagIds: ['t2', 't3'] }, }, tags: { 't1': { id: 't1', value: 'tag 1', postIds: ['p1'] }, 't2': { id: 't2', value: 'tag 2', postIds: ['p1', 'p2'] }, 't3': { id: 't3', value: 'tag 3', postIds: ['p2'] }, } }
Notice:
- no data duplication. The data of tag
t2
exists in one place
- relationships a formed by IDs which correspond to their associated entity
Benefits of normalization
Normalized data is easier to reason about. Some benefits are:
- each entity has a single source of truth, so all writes and reads of a given entity occur from one place.
- entities are not nested within other entities, so you can access any entity from a flat list instead of a deep tree.
Using Normalized Reducer
Now the feature presentation — you can manage normalized React state with Normalized Reducer. It abstracts common relational data access patterns into a simple API. As per the name, Normalized Reducer utilizes the reducer pattern. It is agnostic and zero-dependency, so you can use it with any reducer state implementation including React
useReducer
and Redux.This tutorial will walk through how to build a React app with Normalized Reducer. The app will be a bookmarks manager where multiple users can bookmark URL’s. The app will use Material UI for styling, but it is not required for Normalized Reducer. All you need is a working React webapp.
In your app, install Normalized Reducer. Run:
yarn add normalized-reducer
Or
npm install --save normalized-reducer
The Model and Store, and App
We’ll start with the relational model, which has profiles and bookmarks. A profile has many bookmarks, and a bookmark has one profile. With Normalized Reducer, you define relationships in a
schema
object:const schema = { profile: { bookmarkIds: { type: 'bookmark', cardinality: 'many', reciprocal: 'profileId' } }, bookmark: { profileId: { type: 'profile', cardinality: 'one', reciprocal: 'bookmarkIds' } } }
Import
normalized-reducer
and pass in the schema.import makeNormalizedSlice from 'normalized-reducer'; const { emptyState, actionCreators, reducer, selectors, actionTypes, } = makeNormalizedSlice(schema)
The returned variables are what help you manage state in way that is consistent with your model. The variable names should look familiar if you’ve used reducers in React or Redux.
- the
reducer
can be fed to a ReactuserReducer
or ReduxcreateStore
or ReduxcombineReducers
- the
actionCreators
contains functions that can be called and fed into a dispatcher to change state
selectors
contains functions that can be fed state and will return a piece of state
emptyState
is the zero-value object specific to your model
actionTypes
contains constants for utility purposes
Now we will use
reducer
and emptyState
to set up a context+hooks store, and a root App
component.import Container from '@material-ui/core/Container'; // ... const ModelContext = createContext(); function Store({ children }) { const [state, dispatch] = useReducer(reducer, emptyState); const value = { state, dispatch } return ( <ModelContext.Provider value={value}> {children} </ModelContext.Provider>); } export default function App() { return ( <Container maxWidth="sm"> <Store> <div>This is a placeholder component</div> </Store> </Container> ); }
A quick detour for styling
Next, let’s get styling out of the way. You implement your own styling, or none at all, or follow these steps for predefined styling:
- Copy this styles.js into your project. Import it, and use it whenever you see the
style
variable in this tutorial.
- Copy this theme.js file into your project. Import
ThemeProvider
from Material UI and render itApp
with the imported theme.
- Import
CssBaseline
from Material UI and render it inApp
.
It should look like:
import { ThemeProvider } from '@material-ui/core/styles'; import CssBaseline from '@material-ui/core/CssBaseline'; import theme from './theme'; import styles from './styles'; export default function App() { return ( <ThemeProvider theme={theme}> <CssBaseline /> <Container maxWidth="sm" style={styles.container}> <Store> <div>This is a placeholder component</div> </Store> </Container> </ThemeProvider> ); }
Entity Creation
Let’s build our first bit of functionality by allowing the user to create a profile. It will display the profile IDs using the
selectors.getIds
selector, and it will enable profile–creation using the actionCreators.create
action-creator. The component:import Button from '@material-ui/core/Button'; import Card from '@material-ui/core/Card'; // ... function Profiles() { const { state, dispatch } = useContext(ModelContext); const ids = selectors.getIds(state, { type: 'profile' }); const createProfile = () => { const id = randomNumber(); dispatch(actionCreators.create('profile', id, { id })); } return ( <div> <Button onClick={createProfile} color="primary">New Profile</Button> <div style={styles.profilesInner}> {ids.map(id => ( <Card key={id} style={styles.card}> {id} </Card>))} </div> </div> ); } function randomNumber(length) { const n = Math.floor(Math.pow(10, length-1) + Math.random() * (Math.pow(10, length) - Math.pow(10, length-1) - 1)); return n.toString(); }
Notice the arguments passed to
actionCreators.create
. The first is the type of entity. In our case this is either 'profile'
or 'bookmark'
. The second is a unique identifier. The library doesn’t generate IDs, so you must provide one. The last argument, an optional one, is an object of arbitrary data.Next, in
App
, remove the placeholder and render <Profiles/>
instead:export default function App() { return ( <ThemeProvider theme={theme}> <CssBaseline /> <Container maxWidth="sm" style={styles.container}> <Store> <Profiles/> </Store> </Container> </ThemeProvider>); }
Load the app in your browser. Click the
New Profile
button a few times, and you should see some random profile IDs appear on the page. To witness the state changes, console.log
the state
:function Store({ children }) { const [state, dispatch] = useReducer(reducer, emptyState); console.log(state) // ... }
And then have another run through, and the console will log an object that looks like:
{ entities: { profile: { // your ids will be random numbers 'p1': { id: 'p1' }, 'p2': { id: 'p2' }, 'p3': { id: 'p3' }, }, bookmark: { }, }, ids: { profile: ['p1', 'p2', 'p3'], bookmark: [], } }
This is the normalized shape in action. The entity data collection is a flat key-to-object hashmap, and entity order is an array of ids. If you have ever worked with normalizr this will look familiar. Later, when we fill out the rest the app functionality, the state will look like:
{ entities: { profile: { 'p1': { bookmarkIds: ['b1'] }, 'p2': { bookmarkIds: ['b2'] } }, bookmark: { 'b1': { profileId: 'p1' }, 'b2': { profileId: 'p2' } }, }, ids: { profile: ['p1', 'p2'], bookmark: ['b1', 'b2'], } }
Wrapping up Part 1. Notice that the only state management code you had to write was the schema plus a few function calls. No writing reducer logic, action boilerplate, etc. This is the gist of Normalized Reducer.
You can see the final product of Part 1 here:
In Part 2 of the tutorial, we’ll continue implementing functionality, including deleting, updating, and moving/reordering entities.