loading...

React/Redux application with Azure DevOps: Part 7 Use backend from React frontend

kenakamu profile image Kenichiro Nakamura ・9 min read

In the previous post, I implemented backend service with express.

In this article, I update React side to use the backend.

Communicate with backend

I see many examples create api folder and place all api related code there. So I follow the pattern. I also use axios to talk with backend server.

1. Make sure you are in root folder and install axios.

npm install axios @types/axios

2. Create api folder under src folder and add voteAPI.ts

  • Re-define IVote and Vote as I cannot access file outside of src with relative path. There maybe better way
  • VoteData is for http Request/Response
  • I omitted error handling here which I should add (maybe later)
/// voteAPI.ts

import axios from 'axios';

export interface IVote {
    id: string;
    votes: number[];
    candidates: string[]
}

export class Vote implements IVote {

    public id: string;
    public votes: number[];
    public candidates: string[];

    constructor(id:string, votes:number[] = [], candidates:string[] = []) {
        this.id = id;
        this.votes = votes;
        this.candidates = candidates;
    }
}

export class VoteData {
    public vote: Vote;
    constructor(vote: Vote) {
        this.vote = vote;
    }
}

class voteAPI {

    private baseUrl = "/api/votes";

    public async getAsync (id: string): Promise<IVote> {
        const url = `${this.baseUrl}/${id}`;
        const { data } = await axios.get<VoteData>(url);
        return data.vote as IVote;
    }

    public async addAsync (vote: IVote): Promise<IVote> {
        const voteData = new VoteData(vote);
        const { data } = await axios.post<VoteData>(this.baseUrl, voteData);
        return data.vote as IVote;
    }

    public async updateAsync(vote: IVote): Promise<IVote> {
        const voteData = new VoteData(vote);
        const { data } = await axios.put<VoteData>(this.baseUrl, voteData);
        return data.vote as IVote;
    }

    public async deleteAsync(id: string): Promise<boolean> {
        const url = `${this.baseUrl}/${id}`;
        const result = await axios.delete(url);
        return result.status === 200
    }
}
export default voteAPI;

3. Add unit test. Mock axios as usual by using jest.

/// voteAPI.test.ts

import axios from 'axios';
import VoteApi, {Vote, VoteData } from './voteAPI';

const dummyVote = new Vote('1', [0, 0], ['cat', 'dog']);
const voteApi= new VoteApi();

it('getAsync should return a vote', async () => {
    jest.spyOn(axios, 'get').mockResolvedValue({ data: new VoteData(dummyVote)});
    expect(await voteApi.getAsync('1')).toBe(dummyVote);
});

it('addAsync should return the added vote', async () => {
    jest.spyOn(axios, 'post').mockResolvedValue({ data: new VoteData(dummyVote)});
    expect(await voteApi.addAsync(dummyVote)).toBe(dummyVote);
});

it('updateAsync should return the updated vote', async () => {
    jest.spyOn(axios, 'put').mockResolvedValue({ data: new VoteData(dummyVote)});
    expect(await voteApi.updateAsync(dummyVote)).toBe(dummyVote);
});

it('deleteAsync should return the updated vote', async () => {
    jest.spyOn(axios, 'delete').mockResolvedValue({status:200});
    expect(await voteApi.deleteAsync('1')).toBe(true);
});

Async call from Redux

All the API related call should be from Redux store. I use Redux Thunk to support async call. See Usage with Redux Thunk for more detail.

1. Update store.ts in redux folder. Simply add ThunkAction and export.

/// store.ts

import { configureStore, Action } from '@reduxjs/toolkit';
import { ThunkAction } from 'redux-thunk';
import rootReducer, {RootState} from './reducer/rootReducer';

const store = configureStore({
    reducer: rootReducer
});

export type AppDispatch = typeof store.dispatch;
export type AppThunk = ThunkAction<void, RootState, unknown, Action<string>>
export default store;

2. Update voteSlice.ts.

  • Only implement necessary functions, so no add/deleteVote
  • Change CountState property hold IVote and message
  • To update the status, some functions call reducers internally via dispath
/// voteSlice.ts

import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { AppThunk } from '../store'
import voteAPI, {IVote, Vote} from '../../api/voteAPI';

export interface CountState {
    vote: IVote;
    message: string
}

const client = new voteAPI();
const initialState: CountState = {
    vote: {
        id: '1',
        candidates: ['cat', 'dog'],
        votes: [0, 0]
    },
    message: ''
};

const voteSlice = createSlice({
    name: 'vote',
    initialState: initialState,
    reducers: {
        getVoteSuccess(state: CountState, action: PayloadAction<IVote>) {
            state.vote = action.payload;
            state.message = '';
        },
        updateVoteSuccess(state: CountState, action: PayloadAction<IVote>) {
            state.vote = action.payload;
            state.message = '';
        },
        voteCRUDFailure(state: CountState, action: PayloadAction<string>) {
            state.message = action.payload;
        }
    }
});

export const getVote = (id: string): AppThunk => async dispatch => {
    try {
        const result = await client.getAsync(id);
        dispatch(getVoteSuccess(result));
    } catch (err) {
        dispatch(voteCRUDFailure(err.toString()));
    }
}

export const increment = (vote: IVote, candidate: number): AppThunk => async dispatch => {
    try {
        const newvotes = vote.votes.map((i, index) => index === candidate ? i + 1 : i);
        const newVote = new Vote(vote.id, newvotes, vote.candidates) ;
        const result = await client.updateAsync(newVote);
        dispatch(updateVoteSuccess(result));
    } catch (err) {
        dispatch(voteCRUDFailure(err.toString()));
    }
}

export const decrement = (vote: IVote, candidate: number): AppThunk => async dispatch => {
    try {
        const newvotes = vote.votes.map((i, index) => index === candidate && i > 0 ? i - 1 : i);
        const newVote = new Vote(vote.id, newvotes, vote.candidates) ;
        const result = await client.updateAsync(newVote);
        dispatch(updateVoteSuccess(result));
    } catch (err) {
        dispatch(voteCRUDFailure(err.toString()));
    }
}

export const addCandidate = (vote: IVote, candidate: string): AppThunk => async dispatch => {
    try {
        const newvotes = [ ...vote.votes, 0];
        const newcandidates = [...vote.candidates, candidate];
        const newVote = new Vote(vote.id, newvotes, newcandidates ) ;
        const result = await client.updateAsync(newVote);
        dispatch(updateVoteSuccess(result));
    } catch (err) {
        dispatch(voteCRUDFailure(err.toString()));
    }
}

export const {
    getVoteSuccess,
    updateVoteSuccess,
    voteCRUDFailure
} = voteSlice.actions;

export default voteSlice.reducer;

Async call from React Component

To support async calls in React Component, I can use Effect Hook, which lets me call async method and update the UI once the operation completed.

One caveat is:

By default, effects run after every completed render, but you can choose to fire them only when certain values have changed.

1. Update voteBoxes.tsx to use useEffect.

  • define renderedCandidates for rendering object and change it depending on data
  • Trigger useEffect only when props.id is changed.
/// voteBoxes.tsx

import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../redux/reducer/rootReducer';
import VoteBox from './voteBox';
import { getVote } from '../redux/reducer/voteSlice';

type Props = {
  id: string
}

const Voteboxes: React.FC<Props> = props => {
  const candidates = useSelector(
    (state: RootState) => state.vote.vote.candidates
  );
  const dispatch = useDispatch();

  useEffect(() => {
    dispatch(getVote(props.id));
  }, [dispatch, props.id]);

  let renderedCandidates = candidates !== undefined && candidates.length > 0 ? <div className="voteBoxes">
    {candidates.map((candidate, index) => <VoteBox key={index} index={index} />)}
  </div> : <div>checking</div>;

  return renderedCandidates;
}

export default Voteboxes;

2. Update App.tsx to pass properties. I hardcord "1" at the moment.

<VoteBoxes id ={'1'} />

3. Update voteBox.tsx as well to accomodate the change.

/// voteBox.tsx

import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../redux/reducer/rootReducer';
import { increment, decrement } from '../redux/reducer/voteSlice';

interface voteProps {
  index: number
}

const Votebox: React.FC<voteProps> = props => {
  const dispatch = useDispatch();
  const { vote, count, candidate } = useSelector(
    (state: RootState) => {
      return {
        vote: state.vote.vote,
        count: state.vote.vote.votes[props.index],
        candidate: state.vote.vote.candidates[props.index]
      }
    }
  );

  return <div className="voteBox">
    <div>
      {candidate}:{count}
    </div>
    <button onClick={() => dispatch(increment(vote, props.index))}>+</button>
    <button onClick={() => dispatch(decrement(vote, props.index))}>-</button>
  </div>;
}

export default Votebox;

4. Then update candidateBox.tsx.

/// candidateBox.tsx

import React, {useState} from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { RootState } from '../redux/reducer/rootReducer';
import { addCandidate } from '../redux/reducer/voteSlice';

const CandidateBox: React.FC = () => {
  const { vote } = useSelector(
    (state: RootState) => state.vote
  );

  const [candidate, setCandidate] = useState("");
  const dispatch = useDispatch();

  return <div className="candidateBox">
    <input data-testid="input" type="text" value={candidate} onChange={(e) => {
        setCandidate(e.currentTarget.value);
      }} />
    <button onClick={() => {
      dispatch(addCandidate(vote, candidate));
      setCandidate("");
    }
    }>Add candidate</button>
  </div>;
}

export default CandidateBox;

Okay, that's it. Thanks to Redux, I didn't need to change much, because most components and data/state operations are already isolated.

Manual Test

To test the application, I need to run both backend and frontend.

1. Start backend either by start debugging or simply run npm script in react-backend folder.

npm run start:dev

2. Run front-end in separate terminal.

npm start

3. If you want to debug frontend, change the debug profile and hit F5.
Alt Text

It's great that I can debug both backend and frontend at the same time :)

If the front end cannot reach to backend, make sure you set proxy in project.json.

Unit test

The last part is to write unit tests.

1. Update App.test.tsx first. It's simple as I just need to pass property to VoteBox component.

/// App.test.tsx

import React from 'react';
import ShallowRenderer  from 'react-test-renderer/shallow';
import App from './App';
import VoteBoxes from './components/voteBoxes';
import CandidateBox from './components/candidateBox';
import logo from './logo.svg';

it('render expected component', () => { 
    const renderer = ShallowRenderer.createRenderer();
    renderer.render(<App />);
    const result = renderer.getRenderOutput();
    expect(result.props.children).toEqual(<header className="App-header">
      <VoteBoxes id={"1"}/>
    <CandidateBox />
    <img src={logo} className="App-logo" alt="logo" />
  </header>);
});

2. Update VoteBoxes.test.tsx. I pass different candidates set to test the output.

/// VoteBoxes.test.tsx

import React from 'react';
import ShallowRenderer from 'react-test-renderer/shallow';
import VoteBoxes from './voteBoxes';
import VoteBox from './voteBox';
import { useSelector, useDispatch } from 'react-redux';

jest.mock('react-redux');
const useSelectorMock = useSelector as jest.Mock;
const useDispatchMock = useDispatch as jest.Mock;
const dispatchMock = jest.fn();
beforeEach(() => {
  useDispatchMock.mockReturnValue(dispatchMock);
});

it('should render the initial checking', () => {
  useSelectorMock.mockReturnValueOnce([]);
  const renderer = ShallowRenderer.createRenderer();
  renderer.render(<VoteBoxes id={'1'}/>);
  const result = renderer.getRenderOutput();
  //expect(result.props.children.length).toBe(2);
  expect(result.props.children).toEqual("checking")
});


it('should render two VoteBox', () => {
  useSelectorMock.mockReturnValueOnce(['cat','dog']);
  const renderer = ShallowRenderer.createRenderer();
  renderer.render(<VoteBoxes id={'1'}/>);
  const result = renderer.getRenderOutput();
  expect(result.props.children.length).toBe(2);
  expect(result.props.children.toString()).toBe([<VoteBox index={0} />, <VoteBox index={1} />].toString())
});

3. Update candidateBox.test.tsx.

/// candidateBox.test.tsx

import React from 'react';
import { render, fireEvent } from '@testing-library/react';
import ShallowRenderer  from 'react-test-renderer/shallow';
import CandidateBox from './candidateBox';
import { useDispatch, useSelector } from 'react-redux';
import { Vote } from '../api/voteAPI';

jest.mock('react-redux');
const useSelectorMock = useSelector as jest.Mock;
const useDispatchMock = useDispatch as jest.Mock;

const dummyFunc = jest.fn();
const dummyVote = new Vote('1', [0, 0], ['cat', 'dog']);

beforeEach(() => {
  useDispatchMock.mockReturnValue(dummyFunc);  
  useSelectorMock.mockReturnValueOnce(dummyVote);
});

it('should render expected element', () => {
  const renderer = ShallowRenderer.createRenderer();
  renderer.render(<CandidateBox />);
  const result = renderer.getRenderOutput();
  expect(result).toMatchSnapshot();
});

it('should call dispatch once when click add candidate', () => {
  const candidate = 'rabbit';
  const { getByText, getByTestId } = render(<CandidateBox />);
  fireEvent.change(getByTestId("input"), { target: { value: candidate } });
  fireEvent.click(getByText(/Add candidate/));
  expect(dummyFunc).toBeCalledTimes(1);
});

4. Finally voteSlice.test.ts. This is a bit more complicated. One important thing is to decide what to test. For example, in my way of writing code, I cannot test the logic for increase/decrease the vote count. If I want to test it, then I need to change the way to test by passing differnt dataset.

  • Pass mock dispatch and getState for async call and test with last called function
  • Mock async function with jest.spyOn and return the result with Promise.resolve or reject
/// voteSlice.test.ts

import vote, {
  getVoteSuccess,
  updateVoteSuccess,
  voteCRUDFailure,
  CountState,
  getVote,
  increment,
  decrement,
  addCandidate
} from './voteSlice'
import { PayloadAction } from '@reduxjs/toolkit';
import voteAPI, {IVote, Vote} from '../../api/voteAPI';

const dummyVote = new Vote('1', [0, 0], ['cat', 'dog']);
const dispatch = jest.fn();
const getState = jest.fn();
const initialState: CountState = {
  vote: {
    id: '1',
    candidates: [],
    votes: []
  },
  message: ''
};

it('should be able to get vote', () => {
  const action: PayloadAction<IVote> = {
    type: getVoteSuccess.type,
    payload:  {
      id: '1',
      candidates: ['cat'],
      votes: [0]
    }
  };
  expect(vote(initialState, action)).toEqual({
    vote: {
      id: '1',
      candidates: ['cat'],
      votes: [0]
    },
    message: ''
  })
});

it('should be able to update vote', () => {
  const action: PayloadAction<IVote> = {
    type: updateVoteSuccess.type,
    payload:  {
      id: '1',
      candidates: ['cat'],
      votes: [0]
    }
  };
  expect(vote(initialState, action)).toEqual({
    vote: {
      id: '1',
      candidates: ['cat'],
      votes: [0]
    },
    message: ''
  })
});

it('should be able to get error', () => {
  const action: PayloadAction<string> = {
    type: voteCRUDFailure.type,
    payload: 'something went wrong'
  };
  expect(vote(initialState, action)).toEqual({
    vote: {
      id: '1',
      candidates: [],
      votes: []
    },
    message: 'something went wrong'
  })
});

it('getVote should dispatch getVoteSuccess on success', async () => {
  jest.spyOn(voteAPI.prototype, 'getAsync').mockReturnValue(
    Promise.resolve(dummyVote));

  await getVote('1')(dispatch, getState, []);
  expect(dispatch).toHaveBeenLastCalledWith(getVoteSuccess(dummyVote));
});

it('getVote should dispatch voteCRUDFailure on failure', async () => {
  jest.spyOn(voteAPI.prototype, 'getAsync').mockReturnValue(
    Promise.reject('error'));
  await getVote('1')(dispatch, getState, []);
  expect(dispatch).toHaveBeenLastCalledWith(voteCRUDFailure('error'));
});

it('increment should dispatch updateVoteSuccess on success', async () => {  
  jest.spyOn(voteAPI.prototype, 'updateAsync').mockReturnValue(
    Promise.resolve(dummyVote));
  await increment(dummyVote, 0)(dispatch, getState, []);  
  expect(dispatch).toHaveBeenLastCalledWith(updateVoteSuccess(dummyVote));
});

it('increment should dispatch voteCRUDFailure on failure', async () => {
  jest.spyOn(voteAPI.prototype, 'updateAsync').mockReturnValue(
    Promise.reject('error'));
  await increment(dummyVote, 0)(dispatch, getState, []);
  expect(dispatch).toHaveBeenLastCalledWith(voteCRUDFailure('error'));
});

it('decrement should dispatch updateVoteSuccess on success', async () => {  
  jest.spyOn(voteAPI.prototype, 'updateAsync').mockReturnValue(
    Promise.resolve(dummyVote));
  await decrement(dummyVote, 0)(dispatch, getState, []);  
  expect(dispatch).toHaveBeenLastCalledWith(updateVoteSuccess(dummyVote));
});

it('decrement should dispatch voteCRUDFailure on failure', async () => {
  jest.spyOn(voteAPI.prototype, 'updateAsync').mockReturnValue(
    Promise.reject('error'));
  await decrement(dummyVote, 0)(dispatch, getState, []);
  expect(dispatch).toHaveBeenLastCalledWith(voteCRUDFailure('error'));
});

it('addCandidate should dispatch updateVoteSuccess on success', async () => {  
  jest.spyOn(voteAPI.prototype, 'updateAsync').mockReturnValue(
    Promise.resolve(dummyVote));
  await addCandidate(dummyVote, 'rabbit')(dispatch, getState, []);  
  expect(dispatch).toHaveBeenLastCalledWith(updateVoteSuccess(dummyVote));
});

it('addCandidate should dispatch voteCRUDFailure on failure', async () => {
  jest.spyOn(voteAPI.prototype, 'updateAsync').mockReturnValue(
    Promise.reject('error'));
  await addCandidate(dummyVote, 'rabbit')(dispatch, getState, []);
  expect(dispatch).toHaveBeenLastCalledWith(voteCRUDFailure('error'));
});

After update unit test, confirm everything works as expected.
Alt Text

Summary

In this article, I consume the backend service which connects to Redis Cache from frontend. I also added unit test with different technics to accommodate the changes.

In the next article, I will look into how to publish the backend and frontend as one application.

Go to next article

Discussion

pic
Editor guide