Appearance
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>
...
);
}