Demo App using React/Redux/Typescript and Hooks





5.00/5 (2 votes)
Small demo app using React/Redux and hooks
Table of Contents
Overview
It has been a while since I wrote an article here at CodeProject and since I stopped writing articles, I have seen many interesting articles, many by Honey The Code Witch, and I thought it was time I started writing articles again. This one is around React/Redux/TypeScript, which I know there are already lots of. But what I wanted to do is explore using React hooks, and Redux hooks. As such, this article will be based around a simple WebApi backend and a fairly straightforward React front end that uses Redux and hooks where possible.
Hooks are a fairly new feature that allow to use state and other React features without creating classes.
There are numerous posts on how to convert your existing React classes into Hook based components such as these:
- Convert a React Class-Based Component to a Functional One Using a State Hook
- 10 Steps to Convert React Class Component to React Functional Component with Hooks
- Convert Your Class Component to a Functional One With React Hooks
- 5 Ways to Convert React Class Components to Functional Components w/ React Hooks
As such, I will not be covering that in any depth.
So What Does the App Do?
The app is quite simple. It does the following:
- Has two pages, Home and Search which are made routable via React Router
- The Home page shows a d3 force directed graph of electronic music genres. This is a hard coded list. When you click on a node, it will call a backend WebApi and gather some data (Lorem Ipsum text) about the node you selected, which will be shown in a slide out panel.
- The Search page allows you to pick from a hardcoded list of genres, and once one is selected, a call to the backend WebApi will happen, at which point, some images of some hardcoded (server side) items matching the selected genre will be shown. You can then click on them and see more information in a Boostrap popup.
That is all it does, however as we will see, there is enough meat here to get our teeth into. This small demo app is enough to demonstrate things like:
- using d3 with TypeScript
- using Redux with TypeScript
- how to create custom components using both React hooks, and Redux hooks
Demo Video
There is a demo of the finished demo app here.
Where is the Code?
The code for this article is available at https://github.com/sachabarber/DotNetReactRedux. You just need to run npm install
after you download it. Then it should just run as normal from within Visual Studio.
BackEnd
As I say, the backend for this article is a simple WebApi, where the following Controller
class is used.
GenreController
There are essentially two routes:
info/{genre}
: This is used by the D3 force directed graph on the Home page of the FrontEnd site which we will see later. Basically what happens is when you click a node in the graph, it calls this endpoint, and will display some Lorem Ipsum text for the selected nodes Genre.details/{genre}
: This is used on the search screen where we get a list of some hardcoded genre items, which are displayed in response to a search.
The only other thing of note here is that I use the Nuget package LoremNET to generate the Lorem Ipsum.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using DotNetCoreReactRedux.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Formatters;
namespace DotNetCoreReactRedux.Controllers
{
[ApiController]
[Route("[controller]")]
public class GenreController : ControllerBase
{
[Route("info/{genre}")]
[HttpGet]
public GenreInfo Get(string genre)
{
var paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 8, 11);
return new GenreInfo()
{
GenreName = genre,
Paragraphs = paragraphs.ToArray()
};
}
[Route("details/{genre}")]
[HttpGet]
public GenreDetailedItemContainer GetDetailed(string genre)
{
if (GenreDetailsFactory.Items.Value.ContainsKey(genre.ToLower()))
{
return new GenreDetailedItemContainer()
{
GenreName = genre,
Items = GenreDetailsFactory.Items.Value[genre.ToLower()]
};
}
return new GenreDetailedItemContainer()
{
GenreName = genre,
Items = new List<GenreDetailedItem>()
};
}
}
public static class GenreDetailsFactory
{
public static Lazy<Dictionary<string, List<GenreDetailedItem>>> Items =
new Lazy<Dictionary<string,
List<GenreDetailedItem>>>(CreateItems, LazyThreadSafetyMode.None);
private static Dictionary<string, List<GenreDetailedItem>> CreateItems()
{
var items = new Dictionary<string, List<GenreDetailedItem>>();
items.Add("gabber", new List<GenreDetailedItem>()
{
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 3, 11).ToArray(),
Band = "Rotterdam Termination Squad",
Title = "Poing",
ImageUrl = "https://img.discogs.com/OvgtN_-O-4MapL7Hr9L5NUNalF8=/300x300/
filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
discogs-images/R-146496-1140115140.jpeg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 7, 11).ToArray(),
Band = "De Klootzakken",
Title = "Dominee Dimitri",
ImageUrl = "https://img.discogs.com/nJ2O1mYa4c5nkIZcuKK_6wN-lH0=/
fit-in/300x300/filters:strip_icc():format(jpeg):mode_rgb()
:quality(40)/discogs-images/R-114282-1085597479.jpg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 8, 11).ToArray(),
Band = "Neophyte",
Title = "Protracker Ep",
ImageUrl = "https://img.discogs.com/YC8l_-aoYt-OcLNTntu57FIA5w8=/
300x300/filters:strip_icc():format(jpeg):mode_rgb():
quality(40)/discogs-images/R-5039-1149857244.jpeg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 2, 11).ToArray(),
Band = "Disciples Of Belial",
Title = "Goat Of Mendes",
ImageUrl = "https://img.discogs.com/vHAvCPck9EHzi78PG5HDtAMxv0M=/
fit-in/300x300/filters:strip_icc():format(jpeg):mode_rgb()
:quality(40)/discogs-images/R-160557-1546568764-3706.jpeg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 7, 11).ToArray(),
Band = "Bloodstrike",
Title = "Pathogen",
ImageUrl = "https://img.discogs.com/SAqIcgp3kiqPaSVZsGn-oh8E4RE=/
fit-in/300x300/filters:strip_icc():format(jpeg):mode_rgb()
:quality(40)/discogs-images/R-18210-1448556049-2613.jpeg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 3, 11).ToArray(),
Band = "Mind Of Kane",
Title = "The Mind EP",
ImageUrl = "https://img.discogs.com/Hc_is4Ga5A1704qshrkXp9LkhKM=/
300x300/filters:strip_icc():format(jpeg):mode_rgb():
quality(40)/discogs-images/R-160262-1557585935-9794.jpeg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 5, 11).ToArray(),
Band = "Stickhead",
Title = "Worlds Hardest Kotzaak",
ImageUrl = "https://img.discogs.com/HFKhwj9ZfVEwLW0YJm_rUHx75lU=/
fit-in/300x300/filters:strip_icc():format(jpeg):mode_rgb()
:quality(40)/discogs-images/R-20557-1352933734-5019.jpeg.jpg"
},
});
items.Add("acid house", new List<GenreDetailedItem>()
{
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 5, 11).ToArray(),
Band = "Various",
Title = "ACid House",
ImageUrl = "https://img.discogs.com/WmSfj73-GK0TQhpLZTnLaEqWvdU=/
300x300/filters:strip_icc():format(jpeg):mode_rgb():
quality(40)/discogs-images/R-1224150-1264336074.jpeg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 3, 11).ToArray(),
Band = "Rififi",
Title = "Dr Acid And Mr House",
ImageUrl = "https://img.discogs.com/3w5QDa6y7PK7tYZ99hzPnMdxIVE=/300x300/
filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
discogs-images/R-195695-1484590974-8359.jpeg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 6, 11).ToArray(),
Band = "Tyree",
Title = "Acid Over",
ImageUrl = "https://img.discogs.com/rQVeuPgGK0ksQ-g2xJEWrx1ktnc=/300x300/
filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
discogs-images/R-61941-1080462105.jpg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 2, 11).ToArray(),
Band = "Acid Jack",
Title = "Acid : Can You Jack",
ImageUrl = "https://img.discogs.com/ojC7tbyzBe9XLpC9-sPtYiSfu4g=/300x300/
filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
discogs-images/R-466567-1155405490.jpeg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 5, 11).ToArray(),
Band = "Bam Bam",
Title = "Wheres Your Child",
ImageUrl = "https://img.discogs.com/RIsPWasW9OV6iJlGW1dF7x5B_Hg=/
fit-in/300x300/
filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
discogs-images/R-43506-1356639075-5067.jpeg.jpg"
},
});
items.Add("drum & bass", new List<GenreDetailedItem>()
{
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 8, 11).ToArray(),
Band = "Bad Company",
Title = "Bad Company Classics",
ImageUrl = "https://img.discogs.com/uArBfSolc15i_Ys5S4auaHYTo8w=/
fit-in/300x300/
filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
discogs-images/R-1138493-1195902484.jpeg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 4, 11).ToArray(),
Band = "Adam F",
Title = "F Jam",
ImageUrl = "https://img.discogs.com/99njVrjJq6ES0l6Va2eTFcjP1AU=/300x300/
filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
discogs-images/R-5849-1237314693.jpeg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 2, 11).ToArray(),
Band = "Diesel Boy",
Title = "A Soldier's Story - A Drum And Bass DJ Mix",
ImageUrl = "https://img.discogs.com/cFV--pJXg69KkvlJ6q8EV8pg218=/
fit-in/300x300/
filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
discogs-images/R-3353-1175897684.jpeg.jpg"
},
new GenreDetailedItem()
{
Paragraphs = LoremNET.Lorem.Paragraphs(8, 9, 4, 5, 4, 11).ToArray(),
Band = "Future Mind",
Title = "Drum & Bass",
ImageUrl = "https://img.discogs.com/R46K8de0GA89HoYxJDjUBDexmgs=/300x300/
filters:strip_icc():format(jpeg):mode_rgb():quality(40)/
discogs-images/R-4685019-1372172049-9885.jpeg.jpg"
},
});
return items;
}
}
}
FrontEnd
The frontend is all based around React/ReactRouter/TypeScript and Redux, where we will use hooks if possible.
General Idea
As stated in the overview, the basic idea is this, where the app:
- has 2 pages Home and Search which are made routable via React Router
- The Home page shows a d3 force directed graph of electronic music genres. This is a hard coded list. When you click on a node, it will call a backend WebApi and gather some data (Lorem Ipsum text) about the node you selected, which will be shown in a slide out panel.
- The Search page allows you to pick from a hardcoded list of genres, and once one is selected, a call to the backend WebApi will happen, at which point some images of some hardcoded (server side) items matching the selected genre will be shown. You can then click on them and see more information in a Boostrap popup.
When you first start the app, it should look like this:
From here, you can click on the nodes in the d3 graph, which will show a slide in information panel, or you can use the Search page as described above.
Create React App / .NET Core React/Redux Starter Template
The application was started using the .NET Core command line dotnet new reactredux
. This gives you some starter code, which includes a sample WebApi/Router/Redux code using TypeScript which also uses the CreateReactApp
template internally. So it is VERY good starting point.
Libraries Used
I have used the following 3rd party libraries in the application.
Redux
I am using Redux, and there are tons of articles on this, so I won't labour this point too much. But for those that don't know Redux provides a state store that happens to work very well with React.
It allows a nice flow where the following occurs:
- React components dispatch actions, which are picked up via a dispatcher.
- The dispatcher pushes the actions through a reducer which is responsible for determining the new state for the store.
- The reducer that creates the new state.
- The React components are made aware of the new state either by using Connect or via hooks. This article uses hooks.
We are able to configure the store and reducers like this:
import { applyMiddleware, combineReducers, compose, createStore } from 'redux';
import thunk from 'redux-thunk';
import { connectRouter, routerMiddleware } from 'connected-react-router';
import { History } from 'history';
import { ApplicationState, reducers } from './';
export default function configureStore(history: History, initialState?: ApplicationState) {
const middleware = [
thunk,
routerMiddleware(history)
];
const rootReducer = combineReducers({
...reducers,
router: connectRouter(history)
});
const enhancers = [];
const windowIfDefined = typeof window === 'undefined' ? null : window as any;
if (windowIfDefined && windowIfDefined.__REDUX_DEVTOOLS_EXTENSION__) {
enhancers.push(windowIfDefined.__REDUX_DEVTOOLS_EXTENSION__());
}
return createStore(
rootReducer,
initialState,
compose(applyMiddleware(...middleware), ...enhancers)
);
}
And this:
import * as Genre from './Genre';
import * as Search from './Search';
// The top-level state object
export interface ApplicationState {
genres: Genre.GenreInfoState | undefined;
search: Search.SearchState | undefined;
}
// Whenever an action is dispatched, Redux will update each top-level application
// state property using the reducer with the matching name.
// It's important that the names match exactly, and that the reducer
// acts on the corresponding ApplicationState property type.
export const reducers = {
genres: Genre.reducer,
search: Search.reducer
};
// This type can be used as a hint on action creators so that its 'dispatch'
// and 'getState' params are
// correctly typed to match your store.
export interface AppThunkAction<TAction> {
(dispatch: (action: TAction) => void, getState: () => ApplicationState): void;
}
ReduxThunk
ReduxThunk is a bit of middleware that allows you dispatch functions to the Redux store. With a plain basic Redux store, you can only do simple synchronous updates by dispatching an action. Middleware extend the store's abilities, and let you write async logic that interacts with the store.
Thunks are the recommended middleware for basic Redux side effects logic, including complex synchronous logic that needs access to the store, and simple async logic like AJAX requests.
This is the entire source code for ReduxThunk, pretty nifty no?
function createThunkMiddleware(extraArgument) {
return ({ dispatch, getState }) => (next) => (action) => {
if (typeof action === 'function') {
return action(dispatch, getState, extraArgument);
}
return next(action);
};
}
const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;
export default thunk;
Where this may be an example of using ReduxThunk:
// This type can be used as a hint on action creators so that
// its 'dispatch' and 'getState' params are
// correctly typed to match your store.
export interface AppThunkAction<TAction> {
(dispatch: (action: TAction) => void, getState: () => ApplicationState): void;
}
export const actionCreators = {
requestSearchInfo: (genre: string):
AppThunkAction<KnownAction> => (dispatch, getState) => {
// Only load data if it's something we don't already have (and are not already loading)
const appState = getState();
if (appState && appState.search && genre !== appState.search.searchInfo.genreName) {
fetch(`genre/details/${genre}`)
.then(response => response.json() as Promise<GenreDetailedItemContainer>)
.then(data => {
dispatch({ type: 'RECEIVE_SEARCH_INFO', genre: genre, searchInfo: data });
});
dispatch({ type: 'REQUEST_SEARCH_INFO', genre: genre });
}
}
};
ScrollBars
I make use of this React component, to achieve nice fancy Scroll Bars in the app, which gives you nice scroll bars like this:
The component is fairly easy to use. You just need to install it via NPM and then use this in your TSX file:
import { Scrollbars } from 'react-custom-scrollbars';
<Scrollbars
autoHeight
autoHeightMin={200}
autoHeightMax={600}
style={{ width: 300 }}>
<div>Some content in scroll</div>
</Scrollbars>
Utilities
I like the idea of internal mediator types buses, and as such, I have included a RxJs based on the demo code here. This is what the service itself looks like:
import { Subject, Observable } from 'rxjs';
export interface IEventMessager
{
publish(message: IMessage): void;
observe(): Observable<IMessage>;
}
export interface IMessage {
}
export class ShowInfoInSidePanel implements IMessage {
private _itemClicked: string;
public constructor(itemClicked: string) {
this._itemClicked = itemClicked;
}
get itemClicked(): string {
return this._itemClicked;
}
}
export class EventMessager implements IEventMessager {
private subject = new Subject<IMessage>();
publish(message: IMessage) {
this.subject.next(message);
}
observe(): Observable<IMessage> {
return this.subject.asObservable();
}
}
And this is what usage of it may look like:
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { IEventMessager } from "./utils/EventMessager";
import { IMessage, ShowInfoInSidePanel } from "./utils/EventMessager";
import { filter, map } from 'rxjs/operators';
export interface HomeProps {
eventMessager: IEventMessager;
}
const Home: React.FunctionComponent<HomeProps> = (props) => {
const dispatch = useDispatch();
const [currentState, setState] = useState(initialState);
useEffect(() => {
const sub = props.eventMessager.observe()
.pipe(
filter((event: IMessage) => event instanceof ShowInfoInSidePanel),
map((event: IMessage) => event as ShowInfoInSidePanel)
)
.subscribe(x => {
....
});
return () => {
sub.unsubscribe();
}
}, [props.eventMessager]);
}
export default Home;
Routing
The demo app makes use of ReactRouter, and Redux, as such the main mount point looks like this. It should be noted that we make use of a ConnectedRouter
which is because we are using Redux, where we provide the store in the outer Provider
component.
import 'bootstrap/dist/css/bootstrap.css';
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { ConnectedRouter } from 'connected-react-router';
import { createBrowserHistory } from 'history';
import configureStore from './store/configureStore';
import App from './App';
import registerServiceWorker from './registerServiceWorker';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.min.js';
// Create browser history to use in the Redux store
const baseUrl = document.getElementsByTagName('base')[0].getAttribute('href') as string;
const history = createBrowserHistory({ basename: baseUrl });
// Get the application-wide store instance, prepopulating with state
// from the server where available.
const store = configureStore(history);
ReactDOM.render(
<Provider store={store}>
<ConnectedRouter history={history}>
<App />
</ConnectedRouter>
</Provider>,
document.getElementById('root'));
registerServiceWorker();
Which makes use of App
, which looks like this:
import * as React from 'react';
import { Route } from 'react-router';
import Layout from './components/Layout';
import Home from './components/Home';
import Search from './components/Search';
import { EventMessager } from "./components/utils/EventMessager";
import './custom.css'
let eventMessager = new EventMessager();
export default () => (
<Layout>
<Route exact path='/' render={(props: any) => <Home {...props}
eventMessager={eventMessager} />} />
<Route path='/search' component={Search} />
</Layout>
);
Which in turn uses this Layout
component:
import * as React from 'react';
import { Container } from 'reactstrap';
import NavMenu from './NavMenu';
export default (props: { children?: React.ReactNode }) => (
<React.Fragment>
<NavMenu/>
<Container className="main">
{props.children}
</Container>
</React.Fragment>
);
Which ultimately uses the NavMenu
component, which is as below:
import * as React from 'react';
import { NavItem, NavLink } from 'reactstrap';
import { Link } from 'react-router-dom';
import './css/NavMenu.css';
import HoverImage from './HoverImage';
import homeLogo from './img/Home.png';
import homeHoverLogo from './img/HomeHover.png';
import searchLogo from './img/Search.png';
import searchHoverLogo from './img/SearchHover.png';
const NavMenu: React.FunctionComponent = () => {
return (
<div className="sidenav">
<NavItem>
<NavLink tag={Link} style={{ textDecoration: 'none' }} to="/">
<HoverImage hoverSrc={homeHoverLogo} src={homeLogo} />
</NavLink>
</NavItem>
<NavItem>
<NavLink tag={Link} style={{ textDecoration: 'none' }} to="/search">
<HoverImage hoverSrc={searchHoverLogo} src={searchLogo} />
</NavLink>
</NavItem>
</div>
);
}
export default NavMenu
The more eagle eyed amongst you will see that this also uses a special HoverImage
component, which is a simple component I wrote to use images to allow navigation. This is shown below.
HoverImage Component
As shown above, the routing makes use of a simple HoverImage
component, which simply allows the user to show a different image on MouseOver
. This is the Components code:
import React, { useState } from 'react';
export interface HoverImageProps {
src?: string;
hoverSrc?: string;
}
const HoverImage: React.FunctionComponent<HoverImageProps> = (props) => {
// Declare a new state variable, which we'll call "count"
const [imgSrc, setSource] = useState(props.src);
return (
<div>
<img
src={imgSrc}
onMouseOver={() => setSource(props.hoverSrc)}
onMouseOut={() => setSource(props.src)} />
</div>
);
}
HoverImage.defaultProps = {
src: '',
hoverSrc:'',
}
export default HoverImage
Home Component
The Home component is a top level route component, which looks like this:
This component makes use of Redux, and calls a backend WebApi using Redux, and also makes use of ReduxThunk. The Redux flow is this:
- We dispatch the
requestGenreInfo
action. - We use the Redux hook to listen to state changes for the
Genres
State.
The most important part of the Home
component markup is shown below:
import React, { useState, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import ForceGraph from './graph/ForceGraph';
import data from "./electronic-genres";
import { Scrollbars } from 'react-custom-scrollbars';
import SlidingPanel, { PanelType } from './slidingpanel/SlidingPanel';
import './css/SlidingPanel.css';
import { IEventMessager } from "./utils/EventMessager";
import { IMessage, ShowInfoInSidePanel } from "./utils/EventMessager";
import { filter, map } from 'rxjs/operators';
import HoverImage from './HoverImage';
import circleLogo from './img/circle.png';
import circleHoverLogo from './img/circleHover.png';
//CSS
import './css/ForceGraph.css';
//redux
import { ApplicationState } from '../store';
import * as GenreStore from '../store/Genre';
export interface HomeProps {
eventMessager: IEventMessager;
}
const initialState = {
isopen: false,
selectedNodeText : ''
}
const Home: React.FunctionComponent<HomeProps> = (props) => {
const dispatch = useDispatch();
const [currentState, setState] = useState(initialState);
useEffect(() => {
const sub = props.eventMessager.observe()
.pipe(
filter((event: IMessage) => event instanceof ShowInfoInSidePanel),
map((event: IMessage) => event as ShowInfoInSidePanel)
)
.subscribe(x => {
//pass callback to setState to prevent currentState
// being a dependency
setState(
(currentState) => ({
...currentState,
isopen: true,
selectedNodeText: x.itemClicked
})
);
});
return () => {
sub.unsubscribe();
}
}, [props.eventMessager]);
React.useEffect(() => {
dispatch(GenreStore.actionCreators.requestGenreInfo(currentState.selectedNodeText));
}, [currentState.selectedNodeText]);
const storeState: GenreStore.GenreInfoState = useSelector(
(state: ApplicationState) => state.genres as GenreStore.GenreInfoState
);
return (
<div>
....
<ForceGraph
width={window.screen.availHeight}
height={window.screen.availHeight}
eventMessager={props.eventMessager}
graph={data} />
....
</div>
);
}
export default Home;
Scrollbars
This is the same ScrollBars as mentioned above.
D3 Force Directed Graph
At the heart of the Home component is a d3 force directed graph. But as I was trying to do things nicely, I wanted to do the d3 Graph in TypeScript. So I started with this great blog post and expanded from there. The example on that blog post breaks the graph down into these 4 areas:
- ForceGraph
- Labels
- Links
- Nodes
ForceGraph
This is the main component which holds the others. And it is this one that is used on the Home component. Here is the code for it:
import * as React from 'react';
import * as d3 from 'd3';
import { d3Types } from "./GraphTypes";
import Links from "./Links";
import Nodes from "./Nodes";
import Labels from "./Labels";
import '../css/ForceGraph.css';
import { IEventMessager } from "../utils/EventMessager";
interface ForceGraphProps {
width: number;
height: number;
graph: d3Types.d3Graph;
eventMessager: IEventMessager;
}
export default class App extends React.Component<ForceGraphProps, {}> {
simulation: any;
constructor(props: ForceGraphProps) {
super(props);
this.simulation = d3.forceSimulation()
.force("link", d3.forceLink().id(function
(node: any, i: number, nodesData: d3.SimulationNodeDatum[]) {
return node.id;
}))
.force("charge", d3.forceManyBody().strength(-100))
.force("center", d3.forceCenter(this.props.width / 2, this.props.height / 2))
.nodes(this.props.graph.nodes as d3.SimulationNodeDatum[]);
this.simulation.force("link").links(this.props.graph.links);
}
componentDidMount() {
const node = d3.selectAll(".node");
const link = d3.selectAll(".link");
const label = d3.selectAll(".label");
this.simulation.nodes(this.props.graph.nodes).on("tick", ticked);
function ticked() {
link
.attr("x1", function (d: any) {
return d.source.x;
})
.attr("y1", function (d: any) {
return d.source.y;
})
.attr("x2", function (d: any) {
return d.target.x;
})
.attr("y2", function (d: any) {
return d.target.y;
});
node
.attr("cx", function (d: any) {
return d.x;
})
.attr("cy", function (d: any) {
return d.y;
});
label
.attr("x", function (d: any) {
return d.x + 5;
})
.attr("y", function (d: any) {
return d.y + 5;
});
}
}
render() {
const { width, height, graph, eventMessager } = this.props;
return (
<svg className="graph-container"
width={width} height={height}>
<Links links={graph.links} />
<Nodes nodes={graph.nodes} simulation={this.simulation}
eventMessager={eventMessager}/>
<Labels nodes={graph.nodes} />
</svg>
);
}
}
Labels
The labels represent the labels for the link, and here is the code:
import * as React from "react";
import * as d3 from "d3";
import { d3Types } from "./GraphTypes";
class Label extends React.Component<{ node: d3Types.d3Node }, {}> {
ref!: SVGTextElement;
componentDidMount() {
d3.select(this.ref).data([this.props.node]);
}
render() {
return <text className="label" ref={(ref: SVGTextElement) => this.ref = ref}>
{this.props.node.id}
</text>;
}
}
export default class Labels extends React.Component<{ nodes: d3Types.d3Node[] }, {}> {
render() {
const labels = this.props.nodes.map((node: d3Types.d3Node, index: number) => {
return <Label key={index} node={node} />;
});
return (
<g className="labels">
{labels}
</g>
);
}
}
Links
The labels represent the links for the graph, and here is the code:
import * as React from "react";
import * as d3 from "d3";
import { d3Types } from "./GraphTypes";
class Link extends React.Component<{ link: d3Types.d3Link }, {}> {
ref!: SVGLineElement;
componentDidMount() {
d3.select(this.ref).data([this.props.link]);
}
render() {
return <line className="link" ref={(ref: SVGLineElement) => this.ref = ref}
strokeWidth={Math.sqrt(this.props.link.value)} />;
}
}
export default class Links extends React.Component<{ links: d3Types.d3Link[] }, {}> {
render() {
const links = this.props.links.map((link: d3Types.d3Link, index: number) => {
return <Link key={index} link={link} />;
});
return (
<g className="links">
{links}
</g>
);
}
}
Nodes
The labels represent the nodes for the graph, and here is the code:
import * as React from "react";
import * as d3 from "d3";
import { d3Types } from "./GraphTypes";
import { IEventMessager } from "../utils/EventMessager";
import { ShowInfoInSidePanel } from "../utils/EventMessager";
class Node extends React.Component<{ node: d3Types.d3Node, color: string,
eventMessager: IEventMessager }, {}> {
ref!: SVGCircleElement;
componentDidMount() {
d3.select(this.ref).data([this.props.node]);
}
render() {
return (
<circle className="node" r={5} fill={this.props.color}
ref={(ref: SVGCircleElement) => this.ref = ref}
onClick={() => {
this.props.eventMessager.publish
(new ShowInfoInSidePanel(this.props.node.id));
}}>>
<title>{this.props.node.id}</title>
</circle>
);
}
}
export default class Nodes extends React.Component
<{ nodes: d3Types.d3Node[], simulation: any, eventMessager: IEventMessager }, {}> {
componentDidMount() {
const simulation = this.props.simulation;
d3.selectAll<any,any>(".node")
.call(d3.drag()
.on("start", onDragStart)
.on("drag", onDrag)
.on("end", onDragEnd));
function onDragStart(d: any) {
if (!d3.event.active) {
simulation.alphaTarget(0.3).restart();
}
d.fx = d.x;
d.fy = d.y;
}
function onDrag(d: any) {
d.fx = d3.event.x;
d.fy = d3.event.y;
}
function onDragEnd(d: any) {
if (!d3.event.active) {
simulation.alphaTarget(0);
}
d.fx = null;
d.fy = null;
}
}
render() {
const nodes = this.props.nodes.map((node: d3Types.d3Node, index: number) => {
return <Node key={index} node={node} color="blue"
eventMessager={this.props.eventMessager} />;
});
return (
<g className="nodes">
{nodes}
</g>
);
}
}
The important part of the node code is that there is an onClick
handler which dispatches a message using the EventMessager
class back to the Home
component which will listen to this event and make the Redux call to get the data for the selected node text.
Redux
This is the Redux code that wires the action creator and Redux store state changes together for the Home
component, where it can be seen that this accepts the requestGenreInfo
which is sent to the backend WebApi (the fetch(`genre/info/${genre}`)
) and a new GenreInfoState
is constructed based on the results which is dispatched via the Redus store back to the Search
components Redux useSelector hook which is listening for this state change:
import { Action, Reducer } from 'redux';
import { AppThunkAction } from './';
// -----------------
// STATE - This defines the type of data maintained in the Redux store.
export interface GenreInfoState {
isLoading: boolean;
genre: string;
genreInfo: GenreInfo;
}
export interface GenreInfo {
genreName: string;
paragraphs: Array<string>;
}
// -----------------
// ACTIONS - These are serializable (hence replayable) descriptions of state transitions.
// They do not themselves have any side-effects;
// they just describe something that is going to happen.
interface RequestGenreInfoAction {
type: 'REQUEST_GENRE_INFO';
genre: string;
}
interface ReceiveGenreInfoAction {
type: 'RECEIVE_GENRE_INFO';
genre: string;
genreInfo: GenreInfo;
}
// Declare a 'discriminated union' type.
// This guarantees that all references to 'type' properties contain one of the
// declared type strings (and not any other arbitrary string).
type KnownAction = RequestGenreInfoAction | ReceiveGenreInfoAction;
// ----------------
// ACTION CREATORS - These are functions exposed to UI components
// that will trigger a state transition.
// They don't directly mutate state,
// but they can have external side-effects (such as loading data).
export const actionCreators = {
requestGenreInfo: (genre: string):
AppThunkAction<KnownAction> => (dispatch, getState) => {
// Only load data if it's something we don't already have
// (and are not already loading)
const appState = getState();
if (appState && appState.genres && genre !== appState.genres.genre) {
fetch(`genre/info/${genre}`)
.then(response => response.json() as Promise<GenreInfo>)
.then(data => {
dispatch({ type: 'RECEIVE_GENRE_INFO', genre: genre, genreInfo: data });
});
dispatch({ type: 'REQUEST_GENRE_INFO', genre: genre });
}
}
};
// ----------------
// REDUCER - For a given state and action, returns the new state.
// To support time travel, this must not mutate the old state.
let pars: string[] = [];
const emptyGenreInfo = { genreName: '', paragraphs: pars };
const unloadedState: GenreInfoState =
{ genre: '', genreInfo: emptyGenreInfo, isLoading: false };
export const reducer: Reducer<GenreInfoState> =
(state: GenreInfoState | undefined, incomingAction: Action): GenreInfoState => {
if (state === undefined) {
return unloadedState;
}
const action = incomingAction as KnownAction;
switch (action.type) {
case 'REQUEST_GENRE_INFO':
return {
genre: action.genre,
genreInfo: state.genreInfo,
isLoading: true
};
case 'RECEIVE_GENRE_INFO':
// Only accept the incoming data if it matches the most recent request.
// This ensures we correctly
// handle out-of-order responses.
var castedAction = action as ReceiveGenreInfoAction;
if (action.genre === state.genre) {
return {
genre: castedAction.genre,
genreInfo: castedAction.genreInfo,
isLoading: false
};
}
break;
}
return state;
};
SlidingPanel
As shown in the demo video at the start of this article, when a D3 node gets clicked, we use a cool sliding panel which shows the results of the node that was clicked. Where a call to the backend WebApi controller was done for the selected node text. The idea is that the node uses the EventMessager
RX class to dispatch a message which the Home
component listens to, and will then set a prop value isOpen
which controls whether the SlidingPanel
is transitioned in or not.
This subscription code is shown below:
useEffect(() => {
const sub = props.eventMessager.observe()
.pipe(
filter((event: IMessage) => event instanceof ShowInfoInSidePanel),
map((event: IMessage) => event as ShowInfoInSidePanel)
)
.subscribe(x => {
//pass callback to setState to prevent currentState
// being a dependency
setState(
(currentState) => ({
...currentState,
isopen: true,
selectedNodeText: x.itemClicked
})
);
});
return () => {
sub.unsubscribe();
}
}, [props.eventMessager]);
And this is the SlidingPanel
component, which I adapted from this JSX version into TypeScript.
import React from 'react';
import { CSSTransition } from 'react-transition-group';
import '../css/SlidingPanel.css';
export enum PanelType {
Top = 1,
Right,
Bottom,
Left,
}
type Nullable<T> = T | null;
export interface SliderProps {
type: PanelType;
size: number;
panelClassName?: string;
isOpen: boolean;
children: Nullable<React.ReactElement>;
backdropClicked: () => void;
}
const getPanelGlassStyle = (type: PanelType, size: number, hidden: boolean):
React.CSSProperties => {
const horizontal = type === PanelType.Bottom || type === PanelType.Top;
return {
width: horizontal ? `${hidden ? '0' : '100'}vw` : `${100 - size}vw`,
height: horizontal ? `${100 - size}vh` : `${hidden ? '0' : '100'}vh`,
...(type === PanelType.Right && { left: 0 }),
...(type === PanelType.Top && { bottom: 0 }),
position: 'inherit',
};
};
const getPanelStyle = (type: PanelType, size: number): React.CSSProperties => {
const horizontal = type === PanelType.Bottom || type === PanelType.Top;
return {
width: horizontal ? '100vw' : `${size}vw`,
height: horizontal ? `${size}vh` : '100vh',
...(type === PanelType.Right && { right: 0 }),
...(type === PanelType.Bottom && { bottom: 0 }),
position: 'inherit',
overflow: 'auto',
};
};
function getNameFromPanelTypeEnum(type: PanelType): string {
let result = "";
switch (type) {
case PanelType.Right:
result = "right";
break;
case PanelType.Left:
result = "left";
break;
case PanelType.Top:
result = "top";
break;
case PanelType.Bottom:
result = "bottom";
break;
}
return result;
}
const SlidingPanel: React.SFC<SliderProps> = (props) => {
const glassBefore = props.type === PanelType.Right || props.type === PanelType.Bottom;
const horizontal = props.type === PanelType.Bottom || props.type === PanelType.Top;
return (
<div>
<div className={`sliding-panel-container
${props.isOpen ? 'active' : ''} 'click-through' `}>
<div className={`sliding-panel-container
${props.isOpen ? 'active' : ''} 'click-through' `}>
<CSSTransition
in={props.isOpen}
timeout={500}
classNames={`panel-container-${getNameFromPanelTypeEnum(props.type)}`}
unmountOnExit
style={{ display: horizontal ? 'block' : 'flex' }}
>
<div>
{glassBefore && (
<div
className="glass"
style={getPanelGlassStyle(props.type, props.size, false)}
onClick={(e: React.MouseEvent<HTMLDivElement,
MouseEvent>) => { props.backdropClicked(); }}
/>
)}
<div className="panel"
style={getPanelStyle(props.type, props.size)}>
<div className={`panel-content
${props.panelClassName || ''}`}>{props.children}</div>
</div>
{!glassBefore && (
<div
className="glass"
style={getPanelGlassStyle(props.type, props.size, false)}
onClick={(e: React.MouseEvent<HTMLDivElement, MouseEvent>)
=> { props.backdropClicked(); }}
/>
)}
</div>
</CSSTransition>
</div>
</div>
</div>
);
}
SlidingPanel.defaultProps = {
type: PanelType.Left,
size: 50,
panelClassName: '',
isOpen: false,
children: null,
backdropClicked: () => null
}
export default SlidingPanel;
Most of the credit for this is really the original authors work. I simplt TypeScripted it up.
Search Component
The Search component is a top level route component, which looks like this:
This component makes use of Redux, and calls a backend WebApi using Redux, and also makes use of ReduxThunk. The Redux flow is this:
- We dispatch the
requestSearchInfo
action. - We use the Redux hook to listen to state changes for the
Search
State.
The most important part of the Search component markup is shown below:
import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { Scrollbars } from 'react-custom-scrollbars';
//css
import './css/Search.css';
//redux
import { ApplicationState } from '../store';
import * as SearchStore from '../store/Search';
export interface SearchProps {
}
interface ISearchState {
selectedItem: string;
selectedSearchItem: SearchStore.GenreDetailedItem;
}
const initialState: ISearchState = {
selectedItem: '',
selectedSearchItem: null
}
const Search: React.FunctionComponent<SearchProps> = () => {
const dispatch = useDispatch();
const [currentState, setState] = useState<ISearchState>(initialState);
const onGenreChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
if (e.target.value === '--') {
return;
}
setState(
{
selectedItem: e.target.value,
selectedSearchItem: null
}
);
}
const onImgMouseDown = (item: SearchStore.GenreDetailedItem) => {
setState(
{
...currentState,
selectedSearchItem: item
}
);
}
const storeState: SearchStore.SearchState = useSelector(
(state: ApplicationState) => state.search as SearchStore.SearchState
);
React.useEffect(() => {
dispatch(SearchStore.actionCreators.requestSearchInfo(currentState.selectedItem));
}, [currentState.selectedItem]);
return (
<div>
.....
</div>
);
}
export default Search;
Scrollbars
This is the same ScrollBars as mentioned above.
Redux
This is the Redux code that wires the action creator and Redux store state changes together for the Search
component, where it can be seen that this accepts the requestSearchInfo
which is sent to the backend WebApi (the fetch(`genre/details/${genre}`)
) and a new SearchState
is constructed based on the results which is dispatched via the Redus store back to the Search
components Redux useSelector hook which is listening for this state change.
import { Action, Reducer } from 'redux';
import { AppThunkAction } from './';
// -----------------
// STATE - This defines the type of data maintained in the Redux store.
export interface SearchState {
isLoading: boolean;
genre: string;
searchInfo: GenreDetailedItemContainer;
}
export interface GenreDetailedItemContainer {
genreName: string;
items: Array<GenreDetailedItem>;
}
export interface GenreDetailedItem {
title: string;
band: string;
imageUrl: string;
paragraphs: Array<string>;
}
// -----------------
// ACTIONS - These are serializable (hence replayable) descriptions of state transitions.
// They do not themselves have any side-effects;
// they just describe something that is going to happen.
interface RequestSearchInfoAction {
type: 'REQUEST_SEARCH_INFO';
genre: string;
}
interface ReceiveSearchInfoAction {
type: 'RECEIVE_SEARCH_INFO';
genre: string;
searchInfo: GenreDetailedItemContainer;
}
// Declare a 'discriminated union' type.
// This guarantees that all references to 'type' properties contain one of the
// declared type strings (and not any other arbitrary string).
type KnownAction = RequestSearchInfoAction | ReceiveSearchInfoAction;
// ----------------
// ACTION CREATORS - These are functions exposed to UI components
// that will trigger a state transition.
// They don't directly mutate state, but they can have external side-effects
// (such as loading data).
export const actionCreators = {
requestSearchInfo: (genre: string):
AppThunkAction<KnownAction> => (dispatch, getState) => {
// Only load data if it's something we don't already have (and are not already loading)
const appState = getState();
if (appState && appState.search && genre !== appState.search.searchInfo.genreName) {
fetch(`genre/details/${genre}`)
.then(response => response.json() as Promise<GenreDetailedItemContainer>)
.then(data => {
dispatch({ type: 'RECEIVE_SEARCH_INFO', genre: genre, searchInfo: data });
});
dispatch({ type: 'REQUEST_SEARCH_INFO', genre: genre });
}
}
};
// ----------------
// REDUCER - For a given state and action, returns the new state.
// To support time travel, this must not mutate the old state.
let items: GenreDetailedItem[] = [];
const emptySearchInfo = { genreName: '', items: items };
const unloadedState: SearchState = { genre: '', searchInfo: emptySearchInfo, isLoading: false };
export const reducer: Reducer<SearchState> =
(state: SearchState | undefined, incomingAction: Action): SearchState => {
if (state === undefined) {
return unloadedState;
}
const action = incomingAction as KnownAction;
switch (action.type) {
case 'REQUEST_SEARCH_INFO':
return {
genre: action.genre,
searchInfo: state.searchInfo,
isLoading: true
};
case 'RECEIVE_SEARCH_INFO':
// Only accept the incoming data if it matches the most recent request.
// This ensures we correctly handle out-of-order responses.
var castedAction = action as ReceiveSearchInfoAction;
if (action.genre === state.genre) {
return {
genre: castedAction.genre,
searchInfo: castedAction.searchInfo,
isLoading: false
};
}
break;
}
return state;
};
Boostrap Popup
I make use of Bootstrap to show a popup which looks like this when run.
Where I used this fairly standard BootStrap code:
<div className="modal fade" id="exampleModal"
role="dialog" aria-labelledby="exampleModalLabel"
aria-hidden="true">
<div className="modal-dialog" role="document">
<div className="modal-content">
<div className="modal-header">
<h5 className="modal-title"
id="exampleModalLabel"
style={{ color: "#0094FF" }}>
{currentState.selectedSearchItem.title}</h5>
<button type="button" className="close"
data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div className="modal-body">
<img className="searchImgPopup"
src={currentState.selectedSearchItem.imageUrl} />
<Scrollbars
autoHeight
autoHeightMin={200}
autoHeightMax={600}
style={{ width: 300 }}>
<div className="mainHeader"
style={{ color: "#0094FF" }}>{currentState.selectedSearchItem.band}</div>
<div className="subHeader">
{currentState.selectedSearchItem.paragraphs.map((para, index) => (
<p key={index}>{para}</p>
))}
</div>
</Scrollbars>
</div>
</div>
</div>
</div>
Conclusion
So I had fun writing this small app. I can't definitely see that by using the Redux hooks' it does clean up the whole Connect -> MapDispatchToProps/MapStateToProps sort of just cleans that up a bit. I tried to get rid of all my class based components and do pure components or functional components, but I sometimes found the TypeScript got in my way a little bit. Overall though I found it quite doable, and I enjoyed the doing the work.
Anyways, hope you all enjoy it, as always votes/comments are welcome.
History
- 28th April, 2020: Initial version