Skip to content
  1. Tutorials

State management using Ogma + React Context API

Check out the ogma-react wrapper for seamless integration between Ogma and React.

React Context API was introduced to make the state management easier in React applications even without the use of any external libraries. It's a powerful API and it has been widely adopted by the React community. It has the same principle as the redux family of state management libraries: there's a "store" (React.Context), "reducers", "actions" and "action creators", all using just the built-in capabilities of the react library.

Graph Context

For this tutorial we have used the simplest setup possible: the visualisation canvas and a control panel that has a button to polulate the store (and thus the visualisation) and 2 indicators of the current UI state: clicked node and hovered node ids.

So the initial state of the part of the application that is responsible for the graph visualisation and its UI is very simple:

js
const defaultState = {
  // we put one node in the tutorial above, so that we wouldn't have
  // to start from a completely blank canvas
  graph: { nodes: [], edges: [] }, // equivalent of Ogma RawGraph
  currentNode: null, // string | null
  hoveredNode: null // string | null
};

This is all we need to express the current state of our little application. Let's move to the code required to set up the Context in the GraphContext.jsx file.

Initializing the Context and Provider

jsx
import React, { createContext, useReducer } from 'react';

const defaultState = {
  graph: { nodes: [{ id: 0 }], edges: [] },
  currentNode: null,
  hoveredNode: null
};

...
export const Context = createContext({});

export const Provider = ({ children }) => {
  const [state, dispatch] = useReducer(reducer, defaultState);

  return (
    <Context.Provider value={{ state, dispatch }}>{children}</Context.Provider>
  );
};

Here we have created the part of the store that holds our graph state and the Provider, the special React component that would provide access to this store to its children. Let's focus now on the line where we introduce the reducer and actions: it's the functions that let us update the state from any of the children and notify the components about the changes that we have made. You will see that in the reducer function that we will use to initialize the provider.

Reducer and actions code

Now we need to add the actual reducer code, it will receive all the state changes and decide which part of the state to update and how:

js
// here we import the constants that we will use to
// detect what kind of change the store has received
import { ADD_GRAPH, SELECT_NODE, HOVER_NODE } from './const';

const reducer = (state, action) => {
  switch (action.type) {
    // new graph parts received, let's make a shallow copy of the graph state
    case ADD_GRAPH:
      return {
        ...state,
        graph: {
          nodes: [...state.graph.nodes, ...action.payload.nodes],
          edges: [...state.graph.edges, ...action.payload.edges]
        }
      };

    // a node has been selected
    case SELECT_NODE:
      return {
        ...state,
        currentNode: action.payload
      };

    // a node has been hovered
    case HOVER_NODE:
      return {
        ...state,
        hoveredNode: action.payload
      };

    // nothing to update
    default:
      return state;
  }
};

Here, depending on the content of the update event we are creating a shallow copy of the state to reflect the changes. It will be used by our components downstream to update the representation and the UI. In order to ensure that we always have the predictable shape of the state updates, we will use the so called actions in our UI and visualisation code. They will use the same constants for the update types (ADD_GRAPH etc) and uniform event shapes ({ type, payload }) so that the reducer can interpret them.

Actions

The actions are pretty much a generic code, but they will make sure your code is clean and easy to test. Let's write them in the actions.js file:

js
import { ADD_GRAPH, SELECT_NODE, HOVER_NODE } from './const';

export const addGraph = graph => ({
  type: ADD_GRAPH,
  payload: graph
});

export const selectNode = id => ({
  type: SELECT_NODE,
  payload: id
});

export const hoverNode = id => ({
  type: HOVER_NODE,
  payload: id
});

As we have said previously, you will use it in the UI and data layer to create the events and dispatch them.

Dispatching the updates

Let's take a look at how we get the access to the dispatching code and generate the state updates. In OgmaWrapper.jsx we have a functional React component that is responsible for our visualisation canvas. We wouldn't focus this time on how the functional components work and why we are using the useEffect React hook to set up the Ogma instance - we have covered it in our react hooks integration tutorial. In short, these hooks are used to avoid re-creating the Ogma instance every time the state update is received and re-use the same instance instead. Let's see how we can connect it to the context to dispatch the updates. For that we will use useContext React hook like that:

js
import { useContext } from 'react';
// here we import the context
import { Context as GraphContext } from './GraphContext';

export default function OgmaWrapper () {
  // here we establish access to the current state and the
  // dispatch function
  const {
    state: { graph },
    dispatch
  } = useContext(GraphContext);
  ...
  // rendering jsx code
}

Let's take a look at how we can connect Ogma events with the state update dispatcher, using the actions.

js
// import the action creators
import { selectNode } from './actions';

export default function OgmaWrapper () {
  // instance and Ogma instance ref initialisation, called only once
  // store connection, called every time when this component is re-rendered
  ...
  // here we set up the dispatching of the events to the store
  useEffect(() => {
    // action dispatcher, it calls the action to create the update event
    // of the type and shape that is expected by the store
    const onNodeClick = id => dispatch(selectNode(id));
    // as suggested by react debug tools, due to ref lifecycle
    const ogma = ogmaRef.current;

    // here is the click handler code, created only once
    const clickHandler = ({ target }) => {
      // here we are using `onNodeClick` dispatcher
      if (target && target.isNode) return onNodeClick(target.getId());
      onNodeClick(null);
    };

    // set up the event listener
    ogma.events.on('click', clickHandler);

    return () => {
      // cleanup code
      ogma.events.off(clickHandler);
    };
    // this means that this effect is called only once
    // when the store is initialized
  }, [dispatch]);
  ...
  // rendering jsx code
}

As you can see, on node click event we are creating an event and dispatching it to the store, so that it can be picked up by the component in our UI that needs to reflect it.

UI updates

In our case, we dispatch a store update every time a node in our visualisation is clicked. We have decided to reflect that in another UI component, called Controls.jsx. Let's see how easy it is to receive and render these state updates.

jsx
// import the Context
import { Context as GraphContext } from './GraphContext.jsx';

export default function Controls() {
  // get access to the store
  const {
    dispatch,
    state: { graph, currentNode, hoveredNode }
  } = useContext(GraphContext);
  // now we can simply render the current state

  return (
    ...
    <tr>
      <td>Clicked:</td>
      <td>
        <code>{currentNode === null ? 'none' : currentNode}</code>
      </td>
    </tr>
    ...
  );
}

This code will make sure the UI component is automatically populated with the current state and render 'none' or the clicked node id in the table row.

Async updates

And this is pretty much what there is to it. One more interesting thing that you might want to take a look at is the asynchronous state updates. This usually comes into the scene when you need to make a data request to the API to request the state update and render it when the request is over. This is the case with the "Add graph" UI that you can see in our demo: it requests the JSON file with the graph from the server and simply dispatches the update once it's done and the graph is received:

jsx
// import action creator
import { addGraph } from './actions';
...
export function Controls() {
  ...
  const onClick = evt => {
    evt.preventDefault();
    // load data async then dispatch event
    // here you can also update the additional UI states to
    // indicate that the loading process is on. Alternatively,
    // you can cenralise it and put it together with the API request code
    getGraph().then(data => dispatch(addGraph(data)));
  };

  // UI code to render the button and disable it once the graph is loaded
  return (
    <div className="Controls">
      <button
        onClick={onClick}
        disabled={graph.nodes.length === 0 ? '' : 'disabled'}
      >
        Load graph
      </button>
      ...
  );
}