Click here to Skip to main content
15,868,016 members
Articles / Web Development / React

Demo App using React/Redux/Typescript and Hooks

Rate me:
Please Sign up or sign in to vote.
5.00/5 (2 votes)
27 Apr 2020CPOL10 min read 13.2K   5  
Small demo app using React/Redux and hooks
This is a small demo article which shows how to use React with Redux using Typescript and React/Redux hooks. It also makes use of a D3 graph and a few custom components. Hopefully, by reading this, you will learn a bit about Redux hooks as well as React 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:

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.

C#
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:

Image 1

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.

Image 2

We are able to configure the store and reducers like this:

JavaScript
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:

JavaScript
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?

JavaScript
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:

JavaScript
// 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:

Image 3

The component is fairly easy to use. You just need to install it via NPM and then use this in your TSX file:

JavaScript
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:

C#
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:

JavaScript
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.

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

Image 4

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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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:

JavaScript
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.

JavaScript
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:

Image 5

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:

JavaScript
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.

JavaScript
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.

Image 6

Where I used this fairly standard BootStrap code:

JavaScript
<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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Software Developer (Senior)
United Kingdom United Kingdom
I currently hold the following qualifications (amongst others, I also studied Music Technology and Electronics, for my sins)

- MSc (Passed with distinctions), in Information Technology for E-Commerce
- BSc Hons (1st class) in Computer Science & Artificial Intelligence

Both of these at Sussex University UK.

Award(s)

I am lucky enough to have won a few awards for Zany Crazy code articles over the years

  • Microsoft C# MVP 2016
  • Codeproject MVP 2016
  • Microsoft C# MVP 2015
  • Codeproject MVP 2015
  • Microsoft C# MVP 2014
  • Codeproject MVP 2014
  • Microsoft C# MVP 2013
  • Codeproject MVP 2013
  • Microsoft C# MVP 2012
  • Codeproject MVP 2012
  • Microsoft C# MVP 2011
  • Codeproject MVP 2011
  • Microsoft C# MVP 2010
  • Codeproject MVP 2010
  • Microsoft C# MVP 2009
  • Codeproject MVP 2009
  • Microsoft C# MVP 2008
  • Codeproject MVP 2008
  • And numerous codeproject awards which you can see over at my blog

Comments and Discussions

 
-- There are no messages in this forum --