Skip to content

Instantly share code, notes, and snippets.

@heygrady
Last active March 17, 2018 00:53
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save heygrady/260c561f1ecdf9aca062b7195198f04a to your computer and use it in GitHub Desktop.
Save heygrady/260c561f1ecdf9aca062b7195198f04a to your computer and use it in GitHub Desktop.
Case Study: Map and List

Case Study: <Map /> and <List />

This is a case-study of sketching out a <Map /> (a Google Map) that shows pins and a sidebar <List /> that shows results. The two components are displaying the same data (items), side-by-side, in two different formats. If I make a change to list of items, both components need to update.

Features:

  • Items
    • Shown as pins on the map
    • Shown as list items in the list
  • Filter the list
    • Removes pins from the map
    • Removes items from the list
  • Draw a circle on the map
    • Only shows pins within the circle
    • Only lists items that are within the circle
  • Drag or zoom the map
    • Updates the pins on the map
    • Updates the list items
  • Click on a "city" link
    • Moves the map center to that city
    • Updates the list items to match

The point here is that we have two very different components that are tightly interwoven. If we're not careful we'll end up designing a difficult-to-maintain set of components.

Initial Sketch: <Map />

Let's start with the map. We're going to smooth over the hard bits and presume that we have a magical withGoogleMap HOC that gives us a usable GoogleMapInstance that we can use. Also by magic, this HOC uses the new React context provider API to allow our child components to work with the GoogleMaps API.

We need to be able to work with the map.

  • Click on a "city" link — there will be some navigation outside the map that allows the user to click on a city name. This should update the map center
  • Drag or zoom the map — when the map is moved or zoomed we need to fetch new results to update the pins and the list items
  • Draw a circle on the map — when we draw a polygon, we need to filter the pins and the list items

Notice that our <Map /> doesn't really deal with pins or polygons or list items at all. It only accepts a center/zoom and provides some event hooks for when it changes center or zoom. We delegate everything else to a child component.

Key idea: a component should only render props and call events.

The reason our map is so simple is because a) we're using an imaginary magic map and b) we're letting all of our child components handle their own data needs.

Key idea: a child component should handle its own data.

import React from 'react'
import PropTypes from 'prop-types'
import withGoogleMap from 'imaginary-magic-place'

import DrawingToolsContainer from './DrawingToolsContainer'
import PinsContainer from './PinsContainer'
import PolygonsContainer from './PolygonsContainer'

const Map = ({ center, GoogleMapInstance, onCenterChange, onZoomChange, zoom }) => {
  return (
    <GoogleMapInstance
      center={center}
      onCenterChange={onCenterChange}
      onZoomChange={onZoomChange}
      zoom={zoom}
    >
      <DrawingToolsContainer />
      <PinsContainer />
      <PolygonsContainer />
    </GoogleMapInstance>
  )
}

Map.propTypes = {
  center: PropTypes.object,
  GoogleMapInstance: PropTypes.element.isRequired,
  onCenterChange: PropTypes.function,
  onZoomChange: PropTypes.function,
  zoom: PropTypes.number,
}

export default withGoogleMap(Map)

MapContainer

We need to wrap our Map in a container to connect it to redux. As we connect more components up to redux our app will take shape.

Key idea: a container should connect a component to redux.

You can see that our mapStateToProps is selecting data from the redux state. I like to think of this as "map redux to react".

And in mapDispatchToProps we're using the event hooks provided by the Map component to dispatch actions. I like to think of this as "map react to redux". Inside the component, we're concerned with firing event hooks. Inside the container we're concerned with dispatching actions. It's important that the function props returned by mapDispatchToProps make sense in the context of the component.

Key idea: the prop names returned by mapDispatchToProps should map to event hooks exposed by the component.

import { connect } from 'react-redux'

import { selectCenter, selectZoom } from 'modules/map/selectors'
import { updateCenter, updateZoom } from 'modules/map/actions'

import Map from './Map'

const mapStateToProps = (state) => ({
  center: selectCenter(state),
  zoom: selectZoom(state)
})

const mapDispatchToProps = (dispatch) => ({
  onCenterChange: (center) => dispatch(updateCenter(center))
  onZoomChange: (zoom) => dispatch(updateZoom(zoom))
})

const MapContainer = connect(mapStateToProps, mapDispatchToProps)(Map)
export default MapContainer

Initial Sketch: <Rail />

We'll call our sidebar that holds the list a Rail. It will hold our list and our filter form. Notice that Rail doesn't take any props at all! Again, we delegate as much as possible to our children components.

import React from 'react'

import FilterFormContainer from './FilterFormContainer'
import ListContainer from './ListContainer'

const Rail = () => {
  return (
    <div>
      <FilterFormContainer />
      <ListContainer />
    </div>
  )
}
export default Rail

<List />

Our initial sketch of the List is incomplete. We need to add in some infinite scrolling behavior to fetch new items as we scroll to the bottom. For this first pass we'll ignore that and keep wiring up the basic behavior.

Notice also that we're only passing the id to the ItemContainer. You may wish to spread the whole item onto the Item component, but spreading props can be difficult for tracing code. By providing the ItemContainer with an ID we can allow it to manage its own data.

Also notice that we're using a simple NoItems component to handle the case where there are no items to display. It's helpful to move this to another component to keep the List focused on its core responsibilities. If someone is trying to debug the "no items" code, they'll be able to easily identify that here.

import React from 'react'
import PropTypes from 'prop-types'

import ItemContainer from './ItemContainer'
import NoItems from './NoItems'

const List = ({ items }) => {
  if (!Array.isArray(items) || items.length === 0) {
    return (<NoItems />)
  }
  return (
    <ol>
      {items.map({ id } => (
        <ItemContainer key={id} id={id} />
      ))}
    </ol>
  )
}

List.propTypes = {
  items: PropTypes.array
}

export default List

ListContainer

Our list container is very simple. It selects items from the state and fetches more items when we scroll to the bottom of the list.

Notice that our List component is in charge of deciding that it has reached "scroll bottom". Perhaps the list decides to fire onScrollBottom when we're near the bottom, with only a few items left. That detail belongs in the List. From the perspective of the container, we only care that the event happened.

While onScrollBottom and fetchMoreItems are inextricably linked we should avoid the temptation to name the prop onNeedToFetchMoreItems. It's important that events be somewhat unrelated to the actions they dispatch. The component should not care what happens in the outside world. A component provides event hooks for the container to use. How an event hook is used is of no concern to the component. From the component's point of view, what happens when onScrollBottom is fired uninteresting.

Key idea: component events should be named according to what happens in the component. Actions should be named according to what happens in the app.

Note also that our items aren't currently paginated. We might in the future decide to enable some type of pagination to go along with our infinite scrolling behavior. We should resist the temptation to add any knowledge of pagination to the List component. Instead, we should return the list of items we need to be rendered. Any notion of pagination should be managed within the redux app, possibly as part of handling onScrollBottom.

import { connect } from 'react-redux'

import { selectItems } from 'modules/items/selectors'
import { fetchMoreItems } from 'modules/items/actions'

import List from './List'

const mapStateToProps = (state) => ({
  items: selectItems(state),
})

const mapDispatchToProps = (dispatch) => ({
  onScrollBottom: () => dispatch(fetchMoreItems())
})

const ListContainer = connect(mapStateToProps, mapDispatchToProps)(List)
export default ListContainer

<Item />

We can see that our item is also delegating responsibility to child components. Here we can see that our item photo is managed by a PhotoContainer and our BadgeContainer renders any necessary badges. As we extend out item in the future, we should continue to bounce functionality out into other components. This keeps our Item focused on rendering itself.

Key idea: each component is selfishly concerned with rendering itself.

Note also that we're passing as few props as possible to our child components. Passing more props can be a maintenance issue. Each new prop is new complexity that need to be sorted out. Usually you can get by with passing a simple ID and letting the child container look up the data it needs.

Notice as well, we're passing individual props for each item attribute we want to render. You may prefer passing in the whole item object and letting the component decide what to render. However, its better for code traceability to be explicit and pass only the props that you use. Passing individual props can also help with shallow prop comparison. It's possible that the whole item changed but the individual props we care about have not.

Key idea: pass only the props this component needs.

import React from 'react'

import BadgeContainer from './BadgeContainer'
import PhotoContainer from './PhotoContainer'

const Item = ({ description, id, onClick, photoId, title }) => {
  return (
    <li onClick={onClick}>
      <BadgeContainer id={id} />
      <PhotoContainer id={photoId} />
      <h2>{title}</h2>
      <p>{description}</p>
    </li>
  )
}

export default Item

ItemContainer

You can see that we're destructuring the item we get from the selector to pull out only the props we intend to render. It's possible that the item contains a whole bunch of attributes and meta data that we don't care about in the component. We can reduce our debugging surface considerably by only passing what's used.

Being so explicit can have a down-side as well. It's possible that we end up needing dozens of props from item, which would mean we need to pass dozens of props. Or, we may decide we need to change which props we use. If we passed the entire item object we could let the component decide what it wants to do. However, we should resist the temptation to pass "too much" to the component in order to "let it do what it wants." Components should only do what you tell them to do!

Key idea: Containers should tell components what to render! Passing more data than a component needs is a performance issue.

Also note that connect has the effect of wrapping our component in a shouldComponentUpdate function that does a shallow comparison. This means that passing shallow props to our child component is likely a performance benefit.

Below we're also showing an example of using react-router-redux to dispatch actions that update the URL. When we click our item we need to select that item in the state and update the URL to match. It would be possibly to do this within the component itself using a Link or an a.

Imagine: if we moved the createItemLocation call to mapStateToProps we could then add a <Link to={location} onClick={onClick}> around the title in the Item component. Then we could remove push from mapDispatchToProps. Often there's more than one way to solve the same problem. In either case, we want to let the container supply the data the component needs.

import { connect } from 'react-redux'
import { push } from 'react-router-redux'

import { selectItemById } from 'modules/items/selectors'
import { selectItem } from 'modules/items/actions'

import Item from './Item'
import createItemLocation from './createItemLocation'

const mapStateToProps = (state, ownProps) => {
  const { id } = ownProps
  const { description, photoId, title } = selectItemById(state, ownProps)
  return {
    description,
    id,
    photoId,
    title
  }
}

const mapDispatchToProps = (dispatch, ownProps) => {
  const { id } = ownProps
  return {
    onClick: () => {
      dispatch(selectItem(id))
      const location = createItemLocation(id)
      dispatch(push(location))
    }
  }
}

const ItemContainer = connect(mapStateToProps, mapDispatchToProps)(Item)
export default ItemContainer

Modeling state

It's worth taking a moment to inspect our state. We're going to be making a few API calls as we go to get our items. Below you can see that we're keeping a cache of previously fetched items as well as caches of previous search results. This allows our app to hold past results and display them instantly. For instance, we keep a cache of each page in our result set. As we request additional pages we can add them to the cache. And if the user scrolls up, we will have instant access to that previously fetched data. This type of design ensures that our app stays responsive and avoids unnecessary duplication of data in the state.

Note: you may be worries about the various caches growing wildly out of control. This can be handled in various ways and is largely up to the implementer. However, as a general approach you could inspect caches after each time they are updated to see if we have "too many" things or if some things are "too old". It does put a burden on your app to maintain its own bloat.

  • items — cache of items; indexed list of all known items
  • photos — cache of photos; indexed list of all known photos
  • search — managing the search results and pagination
    • filters — current filters
    • resultSets — cache of search results; keyed by search query
  • map — tracking the map location
const state = {
  // indexed list of all known items
  items: {
    'a': { // imagine a uuid
      // jsonapi resource
      id: 'a',
      type: 'item',
      attributes: { title: 'Hello', description: 'Hello' },
      relationships: {
        photo: { data: { id: 'b', type: 'photo' } }
      },
      meta: { fetchedAt: 'some-date' }
    }
  },

  // indexed list of all known photos
  photos: {
    'b': {
      id: 'b',
      type: 'photo',
      attributes: { url: 'https://cdn.example.com/photo/b', title: 'Hello' }
    }
  },

  // managing the search results and pagination
  search: {
    // current filters
    filters: {
      keyword: undefined,
      color: 'blue',
      box: 'map-bounds-go-here'
    },
    // keyed by search query, allows for resultSet caches
    resultSets: {
      'color=blue&box=map-bounds-go-here&limit=20': {
        // indexed list of pages, allows for fetching page x
        pages: {
          0: {
            data: ['a'], // array of item ids
            meta: { fetching: false }
          }
        },
        offset: 0, // current page
        limit: 20 // items per page
      }
    }
  },

  // tracking the map location
  map: {
    center: { lat: 0, lng: 0 },
    zoom: 10
  },

  // managed by react-router-redux
  location: {
    pathname: 'map/',
    search: '?color=blue&box=map-bounds-go-here'
  }
}

Some quirks

  • filters should match the URL. If they don't match the URL, the URL should be presumed to be correct.
  • box should match the map. If the filter box and the map center are not in agreement, map should be updated.
  • The box may be illogical if the browser was resized or a link was shared to a different device. In that case the map should reconcile the new box and update the URL.
  • map.center should match the map and the box. The box should take precedent.

What's next?

  • sketching out the modules for working with the redux state
  • sketching out the remaining components and containers for the map
  • working out how to reconcile the redux state
    • when the URL changes (from back/forward button)
    • when the map changes
    • when the API returns new data
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment