Skip to content

Instantly share code, notes, and snippets.

@julianburr
Last active November 6, 2019 00:32
Show Gist options
  • Save julianburr/d86176a845b93ef373a7488f0594e3d4 to your computer and use it in GitHub Desktop.
Save julianburr/d86176a845b93ef373a7488f0594e3d4 to your computer and use it in GitHub Desktop.
Model Generator Workshop

Model Generator Workshop

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:

  1. Redux Boilerplate
  2. Data Fetching
  3. State Normalisation

1. Redux Boilerplate

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");

2. Data Fetching

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: []
    }
  }
};

3. State Normalisation

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.)

4. Advanced Stuff + Looking at Model Generator

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
};

5. The Future

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 :/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment