Managing asynchronous state is a complex problem for front-end apps. With a push-oriented platform like Diffusion, ensuring that updates are properly dispatched across your entire application is vital for building reliable & consistent app experiences.
Within the React ecosystem, one of the most popular state management libraries is Redux, which provides dependable ordering and visibility of state transitions throughout an entire application. However, Redux only provides synchronous actions (i.e. operations that return changes to data immediately). The other half of the equation is Redux Thunk, which operates within Redux and lets you easily perform asynchronous actions on the state tree.
This guide will help you setup Diffusion with Redux + Redux Thunk, and demonstrate some basic best practices on managing Diffusion state within your application with a live updating Counter. While this guide is based on React Native, the examples provided are equally applicable for using Diffusion and Redux with base React.
If you're unfamiliar with Redux, make sure to read the Getting Started with Redux walkthrough. If you're not sure what the difference between normal and asynchronous state transitions are, or why you need to use Redux Thunk, the Redux Advanced Tutorial provides more insight into the details of asynchronous actions.
Set up
To begin, you'll need a project setup with Diffusion and React. If you don't have an existing project, we recommend you follow the Using Diffusion with React Native guide first, as it provides an easy walkthrough to get Diffusion and React Native working together.
Once you have your initial project ready, we'll add Redux, React Redux, and Redux thunk to the dependency list:
npm install -s redux react-redux redux-thunk
Now that you have Redux and Redux Thunk available, you need to create a store that will contain all of the application state, as well as the actions you wish to make available. create the following files in your project directory: types.js, actions.js, reducer.js, and store.js.
In actions.js, only import the types for now:
import * as Types from './types';
In reducer.js, add the following code:
import * as Types from './types';
const initialState = {};
export const reducer = (state = initialState, action) => {
return state;
}
And in store.js, add the following:
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import { reducer } from './reducer';
export const store = createStore(reducer, applyMiddleware(thunk));
You now have the bare minimum of a Redux store implemented! The final setup step is to wire the store into the app. If you're following on from the Diffusion with React Native tutorial, simply replace `App.js` with the following:
import 'node-libs-react-native/globals';
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Provider } from 'react-redux';
import { store } from './store';
export function App () {
return (
<Viewstyle={styles.container}>
</View>
);
}
export default function Container() {
return (
<Provider store={store}>
<App/>
</Provider>
);
}
const styles = StyleSheet.create({
container: {
flex:1,
backgroundColor:'#fff',
alignItems:'center',
justifyContent:'center',
},
});
If you're not using React Native, or are working against an existing project, ensure that you're wrapping your main App component with react-redux's Provider component in your application's entry file.
You now have the basic setup for a React project using Redux + Thunk! The next step will walk you through handling Diffusion's connection state management with Redux.
Connection management
When you call Diffusion#connect, the client SDK attempts to connect to the remote Diffusion service, and if authentication succeeds you'll be provided a Session after a brief period of time. This Session can be in one of several connection states; connected, disconnected, reconnecting or closed. Which state a Session is in can change at any time - especially on mobile, where apps can be put into the background or closed unpredictably.
If you have app components that rely on Diffusion, managing the initiation and status of connections is vital for a bug-free application. This is the power of Redux; it provides a centralised way of managing updates, ensuring every component has a consistent view on your application state model. Let's start by implementing some Redux actions to handle connecting to Diffusion.
Add the following action types to types.js:
export const SET_SESSION = 'SET_SESSION';
export const SET_CONNECTED = 'SET_CONNECTED';
In action.js, add the following:
import { connect as connectDiffusion, datatypes, topics } from 'diffusion';
// Standard Redux actions - they operate synchronously
export const setConnected = (connected) => {
return {
type: Types.SET_CONNECTED,
connected
}
}
export const setSession = (session) => {
return {
type: Types.SET_SESSION,
session
}
}
// Redux Thunks - they dispatch asynchronous actions
export const connect = (options) => {
return async (dispatch) => {
const session = await connectDiffusion({
...options
});
session.on('close', () => {
dispatch(setConnected(false));
dispatch(setSession(null))
});
dispatch(setSession(session));
dispatch(setConnected(true));
return session;
}
}
export const close = () => {
return async (dispatch, getState) => {
const { session } = getState();
if (session) {
session.close();
}
}
}
This creates four actions. The first two are standard Redux actions - used to update the state store with the connection status and a reference to the active Diffusion session. The second two actions are Redux Thunks - actions that allow behaviour to be tied to asynchronous state.
In the connect action, it's important to attach the close listener - this is what allows your application to be notified when the session is closed. Any changes to a session's state should be handled here, and then dispatched via Redux actions.
Now that you've setup the basic actions, the next step is to handle them within reducer.js. Change the initialState variable to the following:
const initialState = {
session: null,
connected: false,
}
And set the reducer function to:
export const reducer = (state = initialState, action) => {
switch (action.type) {
case Types.SET_CONNECTED:
return {
...state,
connected: action.connected
};
case Types.SET_SESSION:
return {
...state,
session: action.session
};
default:
return state;
}
}
The final step is to make use of these new actions within your application. First, within App.js (or wherever you've defined your main app component), import the actions as well as the useDispatch and useSelector methods from react-redux:
import { Text, Button } from 'react-native';
import { connect, close } from './actions';
import { Provider, useDispatch, useSelector } from 'react-redux';
Finally, replace your App component with the following:
export function App () {
const connected = useSelector(state => state.connected);
const dispatch = useDispatch();
const details = {
host: 'Your Diffusion hostname',
principal: 'Your username',
credentials: 'Your password',
reconnect: false
}
const toggleConnect = () => {
if (connected) {
dispatch(close());
} else {
dispatch(connect(details));
}
}
return (
<View style={styles.container}>
<Button onPress={toggleConnect} title={connected ? 'Disconnect' : 'Connect'}/>
<Text>Diffusion status: {connected ? 'connected' : 'not connected'}</Text>
</View>
);
}
The useSelector call allows the component to be updated whenever the store is changed (by the reducer) - you can choose exactly what parts of the state model that the component depends on. The useDispatch method is what's used to call the Redux Thunk actions; the Redux Thunk middleware will automatically detect that you're dispatching an asynchronous action, and handle the coordination of events within Redux.
If you now set the credentials to your Diffusion settings and run this app, you will be able to see how connecting and closing a Session through Redux actions results in your application state being updated automatically.
Subscribing to topics
Now that you have Diffusion connected, the next step is to interact with live data. Much like Redux provides a local application state model, Diffusion provides a distributed state model that can be updated across the Internet in real-time. Handling asynchronous updates from Diffusion is done via Topics as part of the Pub/Sub functionality - which maps very naturally to Redux Actions. For this tutorial, we'll create an interactive counter. Any number of applications can connect and update the counter, and updates will be sent instantly to every user of the app.
Let's start by adding a few new action types to types.js:
export const COUNTER_SUBSCRIBED = 'COUNTER_SUBSCRIBED';
export const COUNTER_UPDATE = 'COUNTER_UPDATE';
Next, in reducer.js, add the counter state to your initialState variable:
const initialState = {
session: null,
connected: false,
counter: {
value: 0,
loaded: false
}
}
And add the following action handlers to the reducer function:
case Types.COUNTER_SUBSCRIBED:
return {
...state,
counter: {
...state.counter,
loaded: true
}
}
case Types.COUNTER_UPDATE:
return {
...state,
counter: {
...state.counter,
...action.counter
}
}
Now that the state model has been setup, you can add the following new methods to actions.js:
export const counterSubscribed = () => {
return {
type: Types.COUNTER_SUBSCRIBED
}
}
export const counterUpdate = (counter) => {
return {
type: Types.COUNTER_UPDATE,
counter
}
}
export const subscribeToCounter = () => {
return async (dispatch, getState) => {
const { connected, session } = getState();
if (session && connected) {
return session.select("counter").then(() => {
dispatch(counterSubscribed());
});
}
throw new Error('Unable to subscribe when not connected');
}
}
Those who are familiar with Diffusion may spot a detail here - the subscribeToCounter action only selects the topic; it doesn't register any stream listeners (i.e. callback functions that actually receive update events). While we could easily perform both actions here, by only calling the idempotent select operation (which asks the Diffusion server to send us updates for this topic), the subscribeToCounter action can be called multiple times without potentially leaking memory or introducing bugs with multiple stream listeners.
Instead, amend the connect action to register a value stream after we connect the Session. The value streams will only receive topic updates when the subscribeToCounter action has been dispatched, and by registering the stream at the point of connection you can ensure that only one listener is attached to the active session:
export const connect = (options) => {
return async (dispatch) => {
const session = await connectDiffusion({
...options
});
// Establish a single value stream to pass topic updates to the reducer
session.addStream('counter', datatypes.json()).on('value', (path, spec, value) => {
dispatch(counterUpdate(value.get()));
});
session.on('close', () => {
dispatch(setConnected(false));
dispatch(setSession(null));
});
dispatch(setSession(session));
dispatch(setConnected(true));
return session;
}
}
A common mistake is to try and create actions that map directly to the Diffusion SDK (e.g. "selectTopic", "addStream"). This mapping may seem natural at first, but it's too general to be useful as your application grows. Actions represent changes to specific parts of your state model; as such, you should endeavour to make any interactions with Diffusion specific to the parts of your data model that you're accessing. The specifics of how that maps to Diffusion topics or message handlers is precisely what Actions are designed to abstract over.
To make these topic updates visible throughout your application, the final step is to make the Redux reducer aware of the actions you just defined. To start, add the following to your imports in App.js:
import { useEffect } from 'react';
import { subscribeToCounter } from './store/diffusion/actions';
And update the main component body to make use of the new Redux state and action:
export function App () {
const connected = useSelector(state => state.connected);
const counter = useSelector(state => state.counter);
const dispatch = useDispatch();
useEffect(() => {
if (connected) {
dispatch(subscribeToCounter());
}
}, [connected]);
const details = {
host: 'Your Diffusion hostname',
principal: 'Your username',
credentials: 'Your password',
reconnect: false
}
const toggleConnect = () => {
if (connected) {
dispatch(close());
} else {
dispatch(connect(details));
}
}
return (
<View style={styles.container}>
<Button onPress={toggleConnect} title={connected ? 'Disconnect' : 'Connect'} />
<Text>{connected ? counter.loaded ? `Counter: ${counter.value}` : 'Loading' : 'Not connected'}</Text>
</View>
);
}
If you run the app now, you will be able to see how connecting to Diffusion will result in a state transition that goes from 'Loading' to the providing the counter's value. In order to ensure that the application only subscribes when connected, the useEffect hook guarantees that the subscribe action is called only when the connection state changes (either due to deliberate opening/closing of a session, or disconnection because the application was backgrounded).
Unfortunately, right now the counter value will only show the default value of 0. To start seeing live updates, let's add add the ability to increment the counter topic.
Updating topics
Updating topics in Diffusion is also an asynchronous operation - which again maps neatly to Redux Thunk actions. In actions.js, add the following action to the end of the file:
export const incrementCounter = () => {
return async (dispatch, getState) => {
const { counter, connected, session } = getState();
if (session && connected) {
const specification = new topics.TopicSpecification(topics.TopicType.JSON);
const update = {
...counter,
value: counter.value + 1,
last_updated: Date.now()
};
return session.topicUpdate.set('counter', datatypes.json(), update, {
specification
});
}
throw new Error('Unable to publish when not connected');
}
}
This action simply updates a single topic, referencing the local state (synchronised by Diffusion) to increment the current value. The only thing left to do is call it from your app component. Import the new action at the top of App.js:
import { incrementCounter } from './store/diffusion/actions';
And then update the body of the main component as follows:
export function App () {
const connected = useSelector(state => state.connected);
const counter = useSelector(state => state.counter);
const dispatch = useDispatch();
useEffect(() => {
if (connected) {
dispatch(subscribeToCounter());
}
}, [connected]);
const details = {
host: 'Your Diffusion hostname',
principal: 'Your username',
credentials: 'Your password',
reconnect: false
}
const toggleConnect = () => {
if (connected) {
dispatch(close());
} else {
dispatch(connect(details));
}
}
const increment = () => {
dispatch(incrementCounter());
}
return (
<View style={styles.container}>
<Button onPress={toggleConnect} title={connected ? 'Disconnect' : 'Connect'} />
<Text>{connected ? counter.loaded ? `Counter: ${counter.value}` : 'Loading' : 'Not connected'}</Text>
{connected ? (<Button onPress={increment} title='Increment'>) : (<></>)}
</View>
);
}
If you run the application now in multiple windows / devices, you will be able to see how you can increment the counter and have your changes immediately replicated across for every connected user. You'll also be observe how the application correctly reflects changes in the background Diffusion connection state - such as when you background the app.
We hope that these examples of using Diffusion with Redux & Redux Thunk will help you build your own reliable, real-time applications with React and Diffusion!