Skip to content

(Deprecated) An idiot's guide to frontend programming, Metabase edition

Tom Robinson edited this page Jun 17, 2019 · 1 revision

NOTE: this document is out of date as does not yet discuss Metabase's entities system, which drastically reduces the need to write Redux actions or reducers to fetch or update data from the backend.

See https://github.com/metabase/metabase/wiki/Frontend:-Entity-Loaders


This is an attempt to collect my current understanding of Metabase’s frontend code. It is written from the perspective of a primarily backend engineering perspective, likely to be mostly incorrect, but hopefully will be useful to others as they reach in and add/fix things. It is intended to be a quick orientation on how the frontend in Metabase consumes the results of a server API.

There’s a lot more to our frontend (obviously) but this will be useful for

  1. Generating scaffolding for your API calls to jumpstart the frontend coding process.
  2. Understanding the idioms in play as you debug issues that span the frontend client and server

Libraries used in this in case you need to look things up:

  1. React
  2. Redux
  3. Redux-actions

Redux:

We use Redux to store frontend client state.

The core idea of Redux is that there is a single immutable object containing all the state of the frontend app. You can only modify this state by emitting Actions that contain a payload. Actions have a “type,” and reducers can subscribe as handlers for a given type. Reducers take the action, its payload and update the store.

Once the store is updated, changes propagate through all react components that have been connected to the store.

We define actions + reducers together in the same file to reduce the number of files that are in use, and this file will typically be named the same as the directory or module it powers (eg, “metabase/admin/admin.js” holds the actions and reducers used in the admin module). This file is sometimes called a “duck” in Redux-land.

State

There is a single global redux object (https://github.com/metabase/metabase/blob/master/frontend/src/metabase/store.js) that has multiple “namespaces”.

Namespaces are created by using the combineReducers function. If there are two reducers named ReducerA and ReducerB, and you create a new reducer const reducer = combineReducers({ReducerA, ReducerB}) then your state object will have a structure of

{ReducerA: <ReducerA’s State Object>, ReducerA: <ReducerA’s State Object>}

If you see things like this.props.state.admin.setup.blah, often times this is a result of nested state created via this combineReducers Pattern.

Actions

We have two kinds of Actions in the frontend codebase. Normal (i.e. immediate) actions and Thunk actions.

Normal actions consist of a type, which is a string. We usually declare these as consts so we can use a variable instead of a magic text constant in other parts of the code. const RESET = "metabase/admin/databases/RESET”;

Then we create an action function via redux-action’s createAction, as so export const reset = createAction(RESET);

To over-simplify, Thunk actions are when you want to do something, wait for it to come back, and then kick off an action with the response. We still give them a name, eg export const FETCH_DATABASES = "metabase/admin/databases/FETCH_DATABASES";

but we then use createThunkAction

// fetchDatabases
export const fetchDatabases = createThunkAction(FETCH_DATABASES, function() {
  return async function(dispatch, getState) {
    try {
      return await MetabaseApi.db_list();
    } catch(error) {
      console.error("error fetching databases", error);
    }
  };
});

Things to note:

Reducers

A reducer is a function accepts a state object an action and returns the new state. In the codebase, however, we use the handleActions helper that takes a map of actions to functions that accept state+payload and return a state.

So continuing the fetchDatabases example, see https://github.com/metabase/metabase/blob/master/frontend/src/metabase/admin/databases/database.js#L163

const databases = handleActions({
  [FETCH_DATABASES]: { next: (state, { payload }) => payload },
  [ADD_SAMPLE_DATASET]: { next: (state, { payload }) => payload ? [...state, payload] : state },
  [DELETE_DATABASE]: (state, { payload: { databaseId} }) => databaseId ? _.reject(state, (d) => d.id === databaseId) : state
}, null);

Here the FETCH_DATABASES reducer just takes the list of databases we saw above, and “replaces" its entire state with that list. Note that since we later combine reducers, the FETCH_DATABASES reducer ends up working with a mini-state in “admin.databases”, which is why it can be so cavalier about nuking everything that was already there … it’s treating its own little sandbox as the entire BigBadGlobalState.

Steps to connect a backend api to the frontend

  1. Write the backend api
  2. Add a line in services.js
  3. Find the appropriate place where it should live in the global state tree (eg, dashboards.new.somefeature), and find the place where those reducers are defined
  4. Write a thunkAction(s) to make each call
  5. Decide how you want to store the results, or modify state according to what the api call does
  6. Write a reducer that returns a new state accordingly.

React:

React is a tirefire that I only half understand, but just to continue the example above, let’s pretend you want to create a view for the results of the api endpoint:

Routes.jsx

Is a component that comprises the entire frontend url routing table for the site.

Containers and Components

We typically create a react component that is responsible for fetching any data, updating it, etc and passing in this into a second component that only deals with visualization. The former tends to be referred to as a Container, and the later a Component. You might also hear SmartComponent and DumbComponent respectively.

However for this example, let’s ignore all that and use a combined container. https://github.com/metabase/metabase/blob/81b5bf643276e57373e0b9b42e7180fecaac6c31/frontend/src/metabase/admin/TroubleshootingApp.jsx is a minimal example that displays my lack of skill and finesse at all this stuff but works.

Important things to note:

Actions can be called in many ways, but we usually bind them to a react component. You do this by putting them in a mapDispatchToProps and then connecting the component to the store with the connect function. Similarly, rather than use the store directly, it’s considered good verbose, soul crushing form to map the parts of the store you care about into the properties of the react component. This verbosity also allows @connect to only re-render components when the data they depend on changes. If you just injected state everything would re-render any time anything in the store changes.

It also isolates the React components from the underlying layout of the Redux store. If want to change something about that you should only need to change the reducers and selectors for that part of the state.

Eg:

const mapStateToProps = state => ({ troubleShootingInfo: state.admin.troubleshooting })
const mapDispatchToProps = { getTroubleshootingInfo }

This is then used to connect the overall app in the final line of most component jsx files ala

export default connect(mapStateToProps, mapDispatchToProps)(TroubleshootingApp)

You might also see @connect used which is syntactic sugar (ES6 decorator) for using methods like connect with ES6 classes.

Within the component, the two things you want to know about:

  • the point of a component is to take what is in its props and poop out some JSX/HTML. the render() function is where that happens. Insert as much wack ass PHP style JSX as you like there.
  • before a component is considered ready to roll, the function componentWillMount is called. put anything you want to load in there.

After you’ve done the above steps a few times and are stuck on debugging or need to know more, read: https://facebook.github.io/react/docs and http://redux.js.org/ There’s a lot I glossed over, ignored or got completely wrong that will no doubt be useful. Note that we also use EMCA 6 in the codebase, which aside from looking like line noise, also makes all the boilerplate a little less verbose.

Clone this wiki locally