This is text version of the draft for the Xendit Trial presentation on Creating Dashboard Template. All the code examples and implementation written below is changed to custom simple cases rather than using the actual dashboard code of Xendit.

Designing Performant, Highly Maintainable Dashboard UI Architecture With Pure React

Table of Contents

Abstract

The objective of this talk is to explore an effective React-based Dashboard Architecture implementation using pure React, along with the advantages and the tradeoff that we need to make.

In this presentation, I will walk through the thinking process and mental models of how each solution is implemented. In the end, we will come up with a template that improves maintainability.

Introduction

The main job of React is to take your application state and turn it into DOM nodes. There are many kinds of state:

  • Model data: The nouns in our application
  • View/UI state: Are those nouns sorted in ascending or descending order?
  • Session state: Is the user even logged in?
  • Communication: Are we in the process of fetching the nouns from the server?
  • Location: Where are we in the application? which nouns are we looking at?

Or, it might make sense to think about state relative to time.

  • Model state: This is likely the data in our application. This could be the items in a given list.
  • Ephemeral state: Stuff like the value of an input field that will be wiped away then you hit "Enter". This could be the order in which a given list is sorted.

Managing state is arguably the hardest part of any application. It's why there are so many state management libraries available and more coming around every day. Although there are different kinds of states in an application, the model state is the data in an application. How to manage the state or data is not clear cut, and is a decision an engineer has to make.

We have to think deeply about what "state" even means in React application. How the class-based component state and hooks differ. Explore APIs for navigating around prop-drilling. We will explore why and how to use reducers for advanced state management, write our own custom hooks for managing state stored in Local Storage, Store state in the URL using query parameters, and fetch state from a server.

Hooks State

Let's start with implementing a simple counter application with hooks:

src/Counter.js
const Counter = ({ max }) => {
  const [count, setCount] = React.useState(0);

  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  const reset = () => setCount(0);

  return (
    <main className="Counter">
      <p className="count">{count}</p>
      <section className="controls">
        <button onClick={increment}>Increment</button>
        <button onClick={decrement}>Decrement</button>
        <button onClick={reset}>Reset</button>
      </section>
    </main>
  );

So, what don't we have to do here?

  • We don't have to bind anything.
  • We dont need a reference to this.
  • We don't need a constructor at all.

It also turns out that useState setters can take functions too.

const increment = () => {
  setCount(c => c + 1);
};

Unlike using values, using functions also works the same way as it does with this.setState .

const increment = () => {
  setCount(c => c + 1);
  setCount(c => c + 1);
  setCount(c => c + 1);
};

Custom Hooks

useEffect allows us to implement some kind of side effect in our component outside of the changes to state and props triggering a new render.

This is useful for a ton of reasons:

  • Storing stuff in localStorage.
  • Making AJAX requests.

Here is a function of getting state from localStorage:

const getStateFromLocalStorage = () => {
  const storage = localStorage.getItem('counterState');
  if (storage) return JSON.parse(storage);
  return { count: 0 };
};

We'll read the count property from localStorage.

const [count, setCount] = React.useState(getStateFromLocalStorage().count);

Now, we'll register a side effect.

React.useEffect(() => {
  localStorage.setItem('counterState', JSON.stringify({ count }));
}, [count]);

The coolest part about this is that it works for increment, decrement, and reset all at once.

Now, let's pulling it out into a custom hook:

const getStateFromLocalStorage = (defaultValue, key) => {
  const storage = localStorage.getItem(key);
  if (storage) return JSON.parse(storage).value;
  return defaultValue;
};

const useLocalStorage = (defaultValue, key) => {
  const initialValue = getStateFromLocalStorage(defaultValue, key);
  const [value, setValue] = React.useState(initialValue);

  React.useEffect(() => {
    localStorage.setItem(key, JSON.stringify({ value }));
  }, [value]);

  return [value, setValue];
};

Now, we just never think about it again.

const [count, setCount] = useLocalStorage(0, 'count');

Reducers

A naive way to communicate between component is by passing the data into the child component all the way to the lowest component:

// Application.js

// this function is passed into child component all the way to the lowest component.
const toggleApproval = id => {
    setApprovals(
      absences.map(absence => {
        if (absence.id !== id) return absence;
        return { ...absence, approved: !absence.approved };
      })
    );
  };

  return (
    <div className="Application">
      <NewRequest onSubmit={addRequest} />
      <Absences absences={absences} onForgive={toggleApproval} />
    </div>
  );

It works, but there are two issues that we'd like to solve for.

  • Prop drillingAbsences needs to receive toggleApproval even though it will never use it. It's just passing it down to Absence.
  • Needless re-renders: Everything re-renders even when we just check a single checkbox. We could try to get clever with some of React's performance helpers—or we can just manage our state better.

Setting up the Debugging tools

  • Turn on the "Highlight updates when components render." feature in the React developer tools.
  • Notice how a checking a checkbox re-renderers everything.
  • Notice how this is not the case in NewRequest.

We could try to get clever here with useCallback and React.memo, but since we're always replacing the array of requests, this is never really going to work out.

Introducing Reducers

Untitled.png

Reducer is a function that takes two arguments, a current state, and an action, and demonstrates how to write a reducer function. With a reducer the state management of the state from the components rendering the the state.

Untitled-1.png

In useState the state management happen in the component while also showing the markup. divorcing the state management from the component.The big win of using reducers is that, reduce function can be unit tested. Usually unit test is not hard, but sometimes we writes code that is not easy to unit test.

Using the reducer

What if we took a different approach to managing state?

Let's make a new file called reducer.js.

const reducer = (state = [], action) => {
  return state;
};

And then we swap out that useState with a useReducer.

const [absences, dispatch] = useReducer(reducer, initialState);

We're going to create an action type and an action creator.

Action Type
const ADD_REQUEST = 'ADD_REQUEST';
const APPROVE_REQUEST = 'APPROVE_REQUEST';
const addRequest = ({ person, reason }) => {
  dispatch({
    type: ADD_REQUEST,
    payload: {
      person,
      reason
    }
  });
};

Action keys manage the state, a dispatch determines what changes are allocated to the state. Why not just use string? you could, but when you make a typo, you don't know the error, and you will have a bad weekend again.

Then, we'll add it to the reducer.

const reducer = (state = [], action) => {
  if (action.type === ADD_REQUEST) {
    return [
      {
        id: id(),
        ...action.payload
      },
      ...state
    ];
  }
  return state;
};

That prop drilling isn't great, but we'll deal with it in a bit.

Next improvement is to tell react that if the props is the same as last time, don't change it or update it. How to do this?

Use React.memo (only checks for prop changes) and useCallback (will return a memoized version of the callback that only changes if one of the inputs has changed). This way, not all requests rerendered, only the one that we care about, rendered.

In order to avoid rendering a component with the same props, react.memo should be used to improve performance. useCallback is also a hook that is useful to improve performance, because it returns a memoized version of the callback that only changes if one of the dependencies has changed.

Memoization And Thoughts On State Management Libraries

Let's see how memoization helps.

  • Wrap the action creators in useCallback
  • Wrap NewRequest and Request in React.memo
  • Notice how we can reduce re-renders

Be mindful when using React.memo , use it sparingly because checking the diff of a component itself is work.


And now.. *The perils prop drilling.*

Prop drilling is the process to get data to parts of the React Component tree. Prop drilling occurs when you have deep component trees, and the Context API provided a way to share values between different components, without having to explicitly pass a prop through every level. The context API allows for better performance.

But, what if the application is getting bigger?

Untitled-2.png

Untitled-3.png

It becomes somewhat problematic just for maintainability. What we want is to skip a lot of that, and like say, "Hey, I want to have the availability of any of the state or data anywhere in this tree and they can hook into it".

Before the Context API is getting released, about two years ago there was a legacy Context API and if you went to the documentation, it was like, "Hey, don't use this". And the reason was that the API was not stable. We don't know how this is gonna change, and if you rely on it then bad things could happen if we break it. There were libraries that used it, as React Redux used it, and if you relied on that library and React made the change, and the library could make a change and you would be okay.

But if you did it bespoke in your own application it was a little trickier, right? You were kind of on your own if the API change. So, if you wanted to skip this prop drilling problem, what you had to do is effectively look at a library like MobX + React or React-Redux. And basically opt into this whole other paradigm of matching state. And there were all sorts of really tricky things you could do with hierarchy components and render props, to try to make it work and like in the previous article, that's what we did because none of this existed yet.

And now, there is a kind of public Context API that you can just use that allows you in the same way Hooks gave us reducer patterns out of the box. So the Context API allows you to solve the prop drilling without having to immediately reach for a tool like Redux or Mob x or something along those lines.

There's still a bunch of really valid reasons why you might want to, but the fact that you do not need to and it's a choice that you don't immediately have to make that jump, I think is really important.

Depends on the case, you might consider Mobx or Redux:

  • Redux out of the box supports middleware. You just plug in multiple middleware, but useReducer does not.
  • Redux also gives you combine reducers a whole bunch of other helpers functions. useReducer does not.

So, if you don't need those things, well why would you pull in a library?

There's a lot of things that come for free in Redux so you don't have to implement yourself. For example, we have to implement a very naive version of middleware. React Redux does a lot of performance optimizations that you could do. There is usually a superset of features beyond what you get out of React. But if you don't need them — far before you need them — if you need any of it, you need all of it

Untitled-4.png

Now, there's a lot more you can do in React before you need any of it. We'll see cases where we use a reducer pattern for very simple things that might will definitely not have justified Redux overall. And you might still use those kinds of in that unique component state where you might use Redux for an overall application. For most of the simple cases, you now have it built into the framework. You don't need to necessarily deal with that extra dependency. But if you need all of the extra functionality and features of these more robust solutions, they are still available for you.

In a complex web application such as dashboards, we will use both Redux and the Context API in different places.

Context API

Untitled-5.png

Context provides a way to pass data through the component tree without having to pass props down manually at every level. It gives you a kind of thread that anything can tap into

When you call React.createContext, you get two components: Provider and Consumer.

import React from 'react';

const SuperCoolContext = React.createContext();

SuperCoolContext.Provider;
SuperCoolContext.Consumer;

How this works is you've gotta provide a component that gets value as a prop. And any component anywhere in that component tree — you can call it the consumer component — they will have access to that value.

<CountContext.Provider value={0}>
	<CountContext.Consumer>
		{ value => <p>{value}</p>}
	</CountContext.Consumer>
</CountContext.Provider>

You can see that in the example above, it is simply a prop. It has no state management of itself. And the way that we're going to handle this is we're gonna take that provider and we're gonna hug it with some state management, like the following:

const CountContext = createContext();

class CountProvider extends Component {
	state = { count: 0 };

	increment = () => this.setState(({ count }) => ({ count: count + 1}));
	decremenet = () => this.setState(({ count }) => ({ count: count - 1}));
	const value = { count, increment, decrement };

	render() {
		const { increment, decrement } = this;
		const { count } = this.state;

		return (
			<CountContext.Provider value={value}>
				{this.props.children}
			</CountContext.Provider>
		);
	}
}

Now, anything in the application can hook into that context and get it anywhere. This idea of separating out our state management from our react application is really a key for a maintainable codebase.


Does it always go around the application? No. If it is only a part of your app that does this, you can implement it just to the smaller part that needs it. For a lot of simple applications, this is for a lot of cases is the answer.

The tradeoff about performance vs maintainability. When it's not really affecting performance, then having maintainability is totally worth it, especially if your team is getting bigger.

Data Fetching

The Basics

Let's take a look in this simple application that fetch all characters in the Star Wars movies.

useEffect(() => {
  console.log('Fetching');
  fetch(`${endpoint}/characters`)
    .then(response => response.json())
    .then(response => {
      console.log({ response });
      setCharacters(Object.values(response.characters));
    })
    .catch(console.error);
});

A careful eye will see that this is actually triggering an infinite loop since we're calling this effect on every single render. Adding a bracket will make this less bad.

useEffect(() => {
  console.log('Fetching');
  fetch(`${endpoint}/characters`)
    .then(response => response.json())
    .then(response => {
      console.log({ response });
      setCharacters(Object.values(response.characters));
    })
    .catch(console.error);
}, []);

This has its own catch since this will only run once when the component mounts and will never run again. But we can burn that bridge later.

Okay, but we want this to run every time the search term changes.

Untitled-6.png

Adding Loading and Error States

const [loading, setLoading] = useState(true);
const [error, setError] = useState(error);

Updating the fetch effect.

useEffect(() => {
  console.log('Fetching');

  setLoading(true);
  setError(null);
  setCharacters([]);

  fetch(`${endpoint}/characters`)
    .then(response => response.json())
    .then(response => {
      setCharacters(Object.values(response.characters));
      setLoading(false);
    })
    .catch(error => {
      setError(error);
      setLoading(false);
    });
}, []);

Then, displaying it in the component

<section className="sidebar">
  {loading ? (
    <p className="loading">Loading…</p>
  ) : (
    <Characters characters={characters} />
  )}
  {error && <p className="error">{error.message}</p>}
</section>

I don't like this. if only there was a way to just announce that things happened and set up a bunch of rules for what the resulting state should be. You don't want to write setLoading every time you do this.

Creating a Custom Hook

If you are not using public api, better make your own custom hook.

const useFetch = url => {
  const [response, setResponse] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    console.log('Fetching');

    setLoading(true);
    setError(null);
    setResponse(null);

    fetch(url)
      .then(response => response.json())
      .then(response => {
        setResponse(response);
        setLoading(false);
      })
      .catch(error => {
        setError(error);
        setLoading(false);
      });
  }, [url]);

  return [response, loading, error];
};

Adding the Formatting in There

Let's break that out into a function.

const formatData = response => (response && response.characters) || [];

Then we can use it like this.

const [characters, loading, error] = useFetch(
  endpoint + '/characters',
  formatData,
);

We can add that to our useEffect.

useEffect(() => {
  console.log('Fetching');

  setLoading(true);
  setError(null);
  setResponse(null);

  fetch(url)
    .then(response => response.json())
    .then(response => {
      setResponse(formatData(response));
      setLoading(false);
    })
    .catch(error => {
      setError(error);
      setLoading(false);
    });
}, [url, formatData]);

Using an Async Function

You can't pass an async function directly to useEffect.

useEffect(() => {
    console.log('Fetching');

    setLoading(true);
    setError(null);
    setResponse(null);

    const get = async () => {
      try {
        const response = await fetch(url);
        const data = await response.json();
        setResponse(formatData(data));
      } catch (error) {
        setError(error);
      } finally {
        setLoading(false);
      }
    };

    get();
  }, [url, formatData]);

  return [response, loading, error];
};

I don't like this, but you might.

Refactoring to a Reducer

const fetchReducer = (state, action) => {
  if (action.type === 'FETCHING') {
    return {
      result: null,
      loading: true,
      error: null,
    };
  }

  if (action.type === 'RESPONSE_COMPLETE') {
    return {
      result: action.payload.result,
      loading: false,
      error: null,
    };
  }

  if (action.type === 'ERROR') {
    return {
      result: null,
      loading: false,
      error: action.payload.error,
    };
  }

  return state;
};

Now, we can just dispatch actions.

const useFetch = (url, dependencies = [], formatResponse = () => {}) => {
  const [state, dispatch] = useReducer(fetchReducer, initialState);

  useEffect(() => {
    dispatch({ type: 'FETCHING' });
    fetch(url)
      .then(response => response.json())
      .then(response => {
        dispatch({
          type: 'RESPONSE_COMPLETE',
          payload: { result: formatResponse(response) },
        });
      })
      .catch(error => {
        dispatch({ type: 'ERROR', payload: { error } });
      });
  }, [url, formatResponse]);

  const { result, loading, error } = state;

  return [result, loading, error];
};

Using reducer is better especially if you have to manage a lot of variety of states (eg: date, address, zip code, in one component). This way, it's super easy to test because you don't have to mock out AJAX and you don't have to mount a component.

Thunks

One of the things that we get in Redux is middleware. And it's true of Redux as it is true of useReducer doesn't really have an idea. the reducer itself does not have an idea of asynchronous. The useFetch is the one that doing all fetch and dispatch in a number of actions. And so in Redux, we have a way to solve this using some middleware. And one of the really common ways to do that is this piece of middleware called Thunk.

What is Thunk?

It's a very complicated way of sayin a very simple thing. Thunk is a function returned from another function.

Untitled-7.png

Thunk can be useful to use if code needs to be executed later since it gives a function that will dispatch an action once it receives results from an API. The major idea behind a thunk is as code to be executed later, it allows us to think about asynchronous code.

useReducer doesn't have an idea of middleware. However, we could theoretically handle this the way that we've been handling other stuff, which is we can just make our own hooks.

const useThunkReducer = (reducer, initialState) => {
  const [state, dispatch] = useReducer(reducer, initialState);

  const enhancedDispatch = useCallback(
    action => {
      if (typeof action === 'function') {
        console.log('It is a thunk');
        action(dispatch);
      } else {
        dispatch(action);
      }
    },
    [dispatch],
  );

  return [state, enhancedDispatch];
};

Now, we just use that reducer instead. We can have a totally separate function for fetching the data that our state management doesn't know anything about.

const fetchCharacters = dispatch => {
  dispatch({ type: 'FETCHING' });
  fetch(endpoint + '/characters')
    .then(response => response.json())
    .then(response => {
      dispatch({
        type: 'RESPONSE_COMPLETE',
        payload: {
          characters: response.characters,
        },
      });
    })
    .catch(error => dispatch({ type: error, payload: { error } }));
};

With useThunkReducer we're gonna separate the asynchronous parts of our application from the synchronous state management pieces, thus in my argument, it will make a more modular code.

This way, before we were doing it when it mounted, here now have it as a function that will go ahead and trigger it on an event. That's basically a way to add extra functionality to the dispatch if the very simple useReducer that React gives you doesn't give you what you want. If dispatch hasn't changed, don't give me a new enhanced dispatch every time render is called, just give me back the same one because it's just calling the same dispatch.

Should you find yourself adding lots and lots of different types of middleware? you could, it's probably a sign that Redux might be a choice for you.

When we lost some of that performance stuff with the context API, React-Redux does a lot more of those performance optimizations for you.

Case: Implementing Undo & Redo (time traveling)

The hardest part of all of this will be understanding time travel. We need to think about the past, present, and future.

What it means to UNDO?

  • Take the most recent past to present,
  • Add present to the past list.

How to keep track of all past, present, and future states?

{
  past: [allPastStates],
  present: currentStateOfTheWorld,
  future: [anyAndAllFutureStates]
}

Notice the data structure, both past and future are arrays, it tracks all the previous states and future states.

We've broken almost everything. So, let's make this a bit better.

const reducer = (state, action) => {
  if (action.type === ADD_REQUEST) {
    return {
      past: [],
      present: [
        {
          id: uniqueId(),
          ...action.payload
        },
        ...state.present
      ],
      future: []
    };
  }

  if (action.type === APPROVE_REQUEST) {
    return {
      past: [],
      present: state.present.filter(absence => absence.id !== action.payload.id),
      future: []
    };
  }

  return state;
};
Adding

Adding to the Stack:

past: [state.present, ...state.past];
if (action.type === UNDO) {
  const [newPresent, ...newPast] = state.past;
  return {
    past: newPast,
    present: newPresent,
    future: [state.present, ...state.present]
  };
}
const undo = useCallback(() => {
  dispatch({ type: UNDO });
}, [dispatch]);
<button disabled={!state.past.length} onClick={undo}>
  Undo
</button>

Then abstracting all of this into a custom reducer:

const useUndoReducer = (reducer, initialState) => {
  const undoState = {
    past: [],
    present: initialState,
    future: []
  };

  const undoReducer = (state, action) => {
    const newPresent = reducer(state, action);

    if (action.type === UNDO) {
      const [newPresent, ...newPast] = state.past;
      return {
        past: newPast,
        present: newPresent,
        future: [state.present, ...state.future]
      };
    }

    if (action.type === REDO) {
      const [newPresent, ...newFuture] = state.future;
      return {
        past: [state.present, ...state.past],
        present: newPresent,
        future: newFuture
      };
    }

    return {
      past: [state.present, ...state.past],
      present: newPresent,
      future: []
    };
  };

  return useReducer(undoReducer, undoState);
};

Did you notice? it just javascript data structure.

Now, anyone can write a regular old reducer if they use undoReducer, instead of useReducer. They get undo & redo functionality for free. they don't have to think about it, we can now use it anywhere in our application.

The take away is how do we take something that is two or three features all together in reusable parts so we are able to ship features really fast by thinking about the state and how it works.

Migration Strategy (WIP)

✏️ If the design incurs non-backwards-compatible changes to an existing system, describe the process whereby entities that depend on the system are going to migrate to the new design.

Impact (WIP)

✏️ Describe the potential impacts of the design on overall performance, security, and other aspects of the system.

Risks (WIP)

✏️ If there are any risks or unknowns, list them here. Also if there is additional research to be done, mention that as well.

Alternatives (WIP)

✏️ If there are other potential solutions which were considered and rejected, list them here, as well as the reason why they were not chosen.

Conclusion (WIP)

The whole idea of hooks is that you can create these abstractions. In this presentation we discuss how it solve some generic things. And I hope that this pattern can help solve any other special snowflake problems that we have.

But for a lot of common things, I would encourage you to see, has this been done already? is there a solution that I can use?

In the real world, sometimes the way we structure a state and what tools we go to grab for use is great. But sometimes useReducer gives us a little more fine-tuning as a separate set state management from the rest of our user interface. With that, it becomes easier to test, because the useReducer is just javascript objects that we don't really have to think about too much. because it's a javascript object it becomes easier to test.

The idea is that we take our business logic, takes a bunch of rules by components, puts it onto the page. If we start to separate states out from the UI if you start to come up which just managing the data structures outside in a reducer — that's just a plain old javascript function — and let that feed into our application in a very declarative way, you can basically say, "okay, the state of the world has changed. React, your components are just a set of rules for how that should render."

We can get to that point if we separate these pieces out, and we can also ratchet it together. Anything that you've done squarely once, you can put into its own little hooks. This way, you never have to see it again, hide your problem. and also reuse the good things.

How we structure states has a big impact on the performance depends on which tradeoffs we wanna make, but for the sake of maintainability.

This is the tradeoff that we have to think about, and this is the tradeoff that we need to make.

Other Improvements To Explore

  • Build our own Design System that to ensure the consistency and scalability in UI Developments
  • Implementing State Machine