Model Generator was created to solve a bunch of core problems we had to deal with in regards to API data flows within our React apps. We wanted to use Redux (because it seemed the best solution to store the data at the time), but needed to address the following:
- Redux Boilerplate
- Data Fetching
- State Normalisation
Normal reducer boilerplate
const ADD = "add";
const SUBTRACT = "subtract";
const SQUARE = "square";
const initialState = {
value: 0
};
export function reducer(state, action) {
switch (action.type) {
case ADD:
return { value: state.value + action.payload };
case SUBTRACT:
return { value: state.value - action.payload };
case SQUARE:
return { value: state.value ** 2 };
default:
return state;
}
}
export function add(amount) {
return { type: ADD, payload: amount };
}
export function subtract(amount) {
return { type: SUBTRACT, payload: amount };
}
export function square() {
return { type: SQUARE };
}
// some component
import { add } from "reducers/counter";
import { connect } from "react-redux";
const mapStateToProps = state => ({
count: state.counter.value
});
const mapDispatchToProps = dispatch => ({
add: amount => dispatch(add(amount))
});
@connect(
mapStateToProps,
mapDispatchToProps
)
class Example extends Component {
//...
}
Reduced
const initialState = {
value: 0
};
const actions = {
add: (state, action) => ({ value: state.value + action.payload }),
subtract: (state, action) => ({ value: state.value - action.payload }),
square: state => ({ value: state.value ** 2 })
};
export default createReducer(initialState, actions);
// some component
import counterReducer from "reducers/counter";
@withStateAndActions(counterReducer)
class Example extends Component {
//...
}
Async actions setup
const ADD_INIT = "add/INIT";
const ADD_SUCCESS = "add/SUCCESS";
const ADD_FAILURE = "add/FAILURE";
const initialState = {
value: 0,
loading: false,
error: null
};
export function reducer(state, action) {
switch (action.type) {
case ADD_INIT:
return {
...state,
loading: true
};
case ADD_SUCCESS:
return {
value: state.value + action.payload,
loading: false,
error: null
};
case ADD_FAILURE:
return {
...state,
loading: false,
error: action.payload.message
};
default:
return state;
}
}
export function add() {
return dispatch => {
dispatch({ type: ADD_INIT });
api
.get("/random/number")
.then(number => {
dispatch({ type: ADD_SUCCESS, payload: number });
})
.catch(error => {
dispatch({ type: ADD_FAILURE, payload: error });
});
};
}
// some component
import { add } from "reducers/counter";
import { connect } from "react-redux";
const mapStateToProps = state => ({
count: state.counter.value,
loading: state.counter.loading,
error: state.counter.error
});
const mapDispatchToProps = dispatch => ({
add: amount => dispatch(add(amount))
});
@connect(
mapStateToProps,
mapDispatchToProps
)
class Example extends Component {
//...
}
Reduced
const initialState = {
value: 0,
loading: false,
error: null
};
const actions = {
add: {
request: () => api.get("/random/number"),
reduce: {
init: state => ({
...state,
loading: true
}),
success: (state, action) => ({
value: state.value + action.payload,
loading: false,
error: null
}),
failure: (state, action) => ({
...value,
loading: false,
error: action.payload.message
})
}
}
};
export default createReducer(initialState, actions);
// some component
import counterReducer from "reducers/counter";
@withStateAndActions(counterReducer)
class Example extends Component {
//...
}
API data example
const initialState = {
items: {},
lists: {}
};
const actions = {
fetchItem: {
request: id => api.get(`/contacts/${id}`),
reduce: {
init: (state, action) => ({
...state,
items: {
...state.items,
[action.originalPayload.id]: {
data: null,
loading: true
}
}
}),
success: (state, action) => ({
...state,
items: {
...state.items,
[action.originalPayload.id]: {
data: action.payload.data,
loading: false,
error: null
}
}
}),
failure: (state, action) => ({
...state,
items: {
...state.items,
[action.originalPayload.id]: {
data: null,
loading: false,
error: action.payload.message
}
}
})
}
},
fetchListPage: {
request: page => api.get("/contacts", { page }),
reduce: {
init: (state, action) => ({
...state,
lists: {
...state.lists,
[action.originalPayload.id]: {
data: null,
loading: true
}
}
}),
success: (state, action) => ({
...state,
lists: {
...state.lists,
[action.originalPayload.id]: {
data: action.payload.data,
loading: false,
error: null
}
}
}),
failure: (state, action) => ({
...state,
lists: {
...state.lists,
[action.originalPayload.id]: {
data: null,
loading: false,
error: action.payload.message
}
}
})
}
},
createItem: {
request: data => api.post("/contacts", data),
reduce: {
init: state => state,
success: (state, action) => ({
...state,
items: {
...state.items,
[action.payload.data.id]: {
data: action.payload.data,
loading: false,
error: null
}
}
}),
failure: state => state
}
},
updateItem: {
request: (id, data) => api.put(`/contacts/${id}`, data),
reduce: {
init: (state, action) => ({
...state,
items: {
...state.items,
[action.originalPayload.id]: {
data: null,
updating: true
}
}
}),
success: (state, action) => ({
...state,
items: {
...state.items,
[action.originalPayload.id]: {
data: action.payload.data,
loading: false,
error: null
}
}
}),
failure: (state, action) => ({
...state,
items: {
...state.items,
[action.originalPayload.id]: {
data: null,
loading: false,
error: action.payload.message
}
}
})
}
},
deleteItem: {
request: id => api.delete(`/contacts/${id}`),
reduce: {
init: state => state,
success: (state, action) => ({
...state,
items: {
...state.items,
[action.originalPayload.id]: undefined
}
}),
failure: state => state
}
}
};
export default createReducer(initialState, actions);
// some component
import counterReducer from "reducers/counter";
@withStateAndActions(counterReducer)
class Example extends Component {
//...
}
Reduced
export default createApiReducer("contacts");
Manually
const mapStateToProps = state => ({
items: state.foo.items
});
const mapDispatchToProps = dispatch => ({
fetch: id => dispatch({ type: "foo/FETCH", payload: id })
});
@connect(
mapStateToProps,
mapDispatchToProps
)
class Example extends Component {
componentDidMount() {
const { items, fetch, id } = this.props;
if (!items[id]) {
fetch(id);
}
}
componentDidUpdate(prevProps) {
const { items, fetch, id } = this.props;
if (prevProps.id !== id) {
if (!items[id]) {
fetch(id);
}
}
}
componentWillUnmount() {
// Do something to let redux know you're not using
// the data anymore, for garbage collection etc.
}
//...
}
HOC
export function withAutoLoadDataFromReducer(name) {
return WrappedComponent => {
const mapStateToProps = state => ({
items: state[name].items
});
const mapDispatchToProps = dispatch => ({
fetch: id => dispatch({ type: name + "/FETCH", payload: id })
});
@connect(
mapStateToProps,
mapDispatchToProps
)
class HOC extends Component {
componentDidMount() {
const { items, fetch, id } = this.props;
if (!items[id]) {
fetch(id);
}
}
componentDidUpdate(prevProps) {
const { items, fetch, id } = this.props;
if (prevProps.id !== id) {
if (!items[id]) {
fetch(id);
}
}
}
componentWillUnmount() {
// Do something to let redux know you're not using
// the data anymore, for garbage collection etc.
}
render() {
return <WrappedComponent {...this.props} {...{ [name]: { items } }} />;
}
}
return HOC;
};
}
// some component
import { withAutoLoadDataFromReducer } from "./hocs";
@withAutoLoadDataFromReducer("contacts")
class Example extends Component {
//...
}
// some other component
<Example id={1} />;
Other concerns
- more options, e.g. putting information on a different prop name, where to get the id from to load a specific item, consumer handling and garbage collection, etc
@withAutoLoadDataFromReducer({
reducerName: "contacts",
id: props => props.contactId,
propName: "contactItem"
//...
})
class Example extends Component {
//...
}
Consumers and Garbage Collection
class Example extends Component {
componentDidMount() {
if (!items[this.props.id]) {
dispatch({ type: "contacts/FETCH/INIT", payload: this.props.id });
}
dispatch({ type: "contacts/CONSUMER/ADD", payload: this.props.id });
}
componentDidUpdate(prevProps) {
const { items, fetch, id } = this.props;
if (prevProps.id !== id) {
dispatch({ type: "contacts/CONSUMER/REMOVE", payload: prevProps.id });
if (!items[this.props.id]) {
dispatch({ type: "contacts/FETCH/INIT", payload: this.props.id });
}
dispatch({ type: "contacts/CONSUMER/ADD", payload: this.props.id });
}
}
componentWillUnmount() {
dispatch({ type: "contacts/CONSUMER/REMOVE", payload: this.props.id });
}
//...
}
Which means, the state for each item (and list) needs to contain a consumer array:
const state = {
items: {
1: {
data: {
//...
},
loading: false,
error: null,
consumers: []
}
}
};
Desired state structure:
const state = {
items: {
[ID]: {
data,
status,
consumers
}
},
lists: {
[UUID]: {
items: [ID, ID, ID],
pagination,
status,
consumers
}
}
};
But also normalisation beyond lists, e.g. for related data:
const apiResponse = {
id: 1,
name: "Pete",
friends: [{ id: 2, name: "John" }, { id: 3, name: "Steve" }]
};
const contactsState = {
items: {
1: {
data: { id: 1, name: "Pete" },
related: {
friends: {
type: "contacts",
value: [2, 3]
}
}
//...
},
2: {
data: { id: 2, name: "John" },
related: {}
//...
},
3: {
data: { id: 3, name: "Steve" },
related: {}
//...
}
}
};
Also for 1:1 relations
const apiResponse = {
id: 1,
name: "Pete",
bestFriend: {
id: 2,
name: "John"
}
};
const contactsState = {
items: {
1: {
data: { id: 1, name: "Pete" },
related: {
bestFriend: {
type: "contacts",
value: 2
}
}
//...
},
2: {
data: { id: 2, name: "John" },
related: {}
//...
}
}
};
Defining relations in the "reducer" through config:
export default createReducer("contacts", {
related: {
friends: {
reducer: ["contacts"],
include: "friends" // = GET contacts?include=friends
},
bestFriend: {
reducer: "contacts"
}
}
});
Other concerns regarding normalisation:
- de-normalisation when connecting to a component (= created need to memoise properly, especially when mapping over normalised lists)
- mapping fields (to improve field names, e.g. for differences between list and item endpoints, to improve values, e.g. formatting dates, etc.)
Why model?
Because its not just a reducer, its a combination of actions, reducers, selectors and meta information (e.g. relations for the normalisations and API request logic)
Not every component cares about all fields
Sometimes already loaded data from a list view can be enough for a details view. So the components should define what data they actually want and the HOC can decide whether or not it needs to do a fetch for that:
@withApiData({
reducerName: "contacts",
id: 1,
fields: {
id: true,
friends: true
}
})
class Example extends Component {
//...
}
This is VERY similar to what GraphQL is really good at:
@withApiData(query`{
${contactsReducer} (id: 1) {
id
friends
}
}`)
class Example extends Component {
//...
}
If a component only cares about a subset of fields, it should also only re-render when any of these fields change, which makes things a bit harder again because of the de-normalisation, so we add etags
const contactsState = {
items: {
1: {
data: { id: 1, name: "Pete" },
related: {
bestFriend: {
type: "contacts",
value: 2
}
},
etag: UUID1
//...
},
2: {
data: { id: 2, name: "John" },
related: {},
etag: UUID2
//...
}
}
};
// resolving item 1 with the following query
query`{
${contactsReducer} (id: 1) {
id
bestFriend {
id
}
}
}`;
const denormalised = {
data: {
id: 1,
bestFriend: {
id: 2
}
},
etag: "UUID1--UUID2"
// ^ combination through relations
// used for mapStateToProps memoization
};
3rd party solutions
Looking (again) at GraphQL libraries (Relay, Apollo, etc) for working with REST APIs given our specific use cases and requirements regarding API design
Hooks
function Example({ id }) {
const { data, loading, error } = useQuery(query`{
${contactsModel} (id: ${id}) {
id
name
}
}`, [id]);
//...
}
Suspense
function Example({ id }) {
const data = useQuery(query`{
${contactsModel} (id: ${id}) {
id
name
}
}`, [id]);
//...
}
<ErrorBoundary>
<Suspense fallback={<p>Loading...</p>}>
<Example id={1} />
</Suspense>
</ErrorBoundary>;
+ moving the data storage to Context / out of Redux
Better integration with routing
const ROUTES = {
CONTACT: {
config: {
path: "/contacts/:id",
Component: ContactDetailsScreen,
queries: [
query`{
${contactsModel} (id: ${params => params.id}) {
id
name
}
}`
]
}
}
};
// which would allow render on fetch architechture, but also
// things like prefetching via links
function ContactsList() {
return (
<Link to={ROUTES.CONTACT} params={{ id: 1 }} prefetch>
Go to contact #1
</Link>
);
}
Combine queries
Being smarter about combining queries from a given page before actually kicking off the API calls.
Smarter caching and garbage collection
I have no idea what that could look like tho :/