Single Responsibility Principle in React applications - Part 3

Jacek Mikrut - Front-end Developer

Jacek Mikrut

15 October 2018, 11 min read

thumbnail post

What's inside

  1. Summary
  2. Conclusion

This is the third and the last part of a miniseries of blog posts. If you haven't done that already, read previous parts of the series first:

In Part 1, we started out with an example of a fat React component. We listed its tasks and extracted one of them, fetching data from GitHub, to a "github" service.

In Part 2, we focused on refactoring internals of the "github" service that opened it for future functionality extensions.

In this part of the series, we’re going back to the React component to delegate its other current responsibilities further.

Freeing the component from data fetching management

In Part 1, we left the LatestGitHubPullRequest component in the following state:

src/components/LatestGitHubPullRequest/LatestGitHubPullRequest.js

import React, { Component, Fragment } from 'react'; import PropTypes from 'prop-types'; import moment from 'moment'; import makePromiseCancelable from '@utils/makePromiseCancelable'; import githubService from '@services/github'; import './LatestGitHubPullRequest.css';

const DATA_FETCHING_STATUS = { NOT_STARTED: Symbol('DATA_FETCHING_STATUS_NOT_STARTED'), IN_PROGRESS: Symbol('DATA_FETCHING_STATUS_IN_PROGRESS'), SUCCESS:     Symbol('DATA_FETCHING_STATUS_SUCCESS'), FAILURE:     Symbol('DATA_FETCHING_STATUS_FAILURE'), };

class LatestGitHubPullRequest extends Component { constructor(props) { super(props); this.state = { dataFetchingStatus: DATA_FETCHING_STATUS.NOT_STARTED, data: null, }; }

componentDidMount() { this.setState({ dataFetchingStatus: DATA_FETCHING_STATUS.IN_PROGRESS, });

const { owner, repo } = this.props;

const fetchPromise = githubService.fetchLatestPullRequest({ owner, repo });

const { promise, cancel } = makePromiseCancelable(fetchPromise); this.cancelDataFetchingPromise = cancel;

promise .then((data) => { this.setState({ dataFetchingStatus: DATA_FETCHING_STATUS.SUCCESS, data }); }) .catch((error) => { if (error.isCanceled) return;

this.setState({ dataFetchingStatus: DATA_FETCHING_STATUS.FAILURE, data: null, }); }); }

componentWillUnmount() { this.cancelDataFetchingPromise(); }

render() { const { dataFetchingStatus, data } = this.state; return ( <div className="c-latest-github-pull-request"> {dataFetchingStatus === DATA_FETCHING_STATUS.NOT_STARTED && ( <Fragment>Initializing...</Fragment>) } {dataFetchingStatus === DATA_FETCHING_STATUS.IN_PROGRESS && ( <Fragment>Fetching...</Fragment> )} {dataFetchingStatus === DATA_FETCHING_STATUS.FAILURE && ( <Fragment>Data fetching error...</Fragment> )} {dataFetchingStatus === DATA_FETCHING_STATUS.SUCCESS && ( <Fragment> <div className="c-latest-github-pull-request__title">{data.title}</div> <div className="c-latest-github-pull-request__body">{data.body}</div> <div className="c-latest-github-pull-request__created-at-and-user-login"> {moment(data.createdAt).calendar()} by {data.userLogin} </div> </Fragment> )} </div> ); } }

LatestGitHubPullRequest.propTypes = { owner: PropTypes.string.isRequired, repo:  PropTypes.string.isRequired, };

export default LatestGitHubPullRequest;


Although the component doesn't need to know from where and how to handle data fetching, it still supervises the process. It calls the githubService.fetchLatestPullRequest(...) method, tracks the fetching status, and cancels the fetch promise if the component happens to be unmounted before the promise resolves.

Note that the mentioned functionality belongs to a logically separate layer. It’s responsible for managing the data fetching process, while the rest of the component is responsible for displaying information.

Let's reflect this in code. What if we extract the data fetching management code to a wrapper component which would then be passing the data and the status down to LatestGitHubPullRequest in props? A higher-order component (hoc) looks like a good candidate for the job:

src/hoc/withLatestGitHubPullRequest.js

import React, { Component } from 'react'; import PropTypes from 'prop-types'; import getComponentDisplayName from '@hoc/utils/getComponentDisplayName'; import makePromiseCancelable from '@utils/makePromiseCancelable'; import githubService from '@services/github'; import DATA_FETCHING_STATUS from '@consts/dataFetchingStatus';

export default function withLatestGitHubPullRequest(OriginalComponent) { class WithLatestGitHubPullRequest extends Component { constructor(props) { super(props); this.state = { dataFetchingStatus: DATA_FETCHING_STATUS.NOT_STARTED, data: null, }; }

componentDidMount() { this.setState({ dataFetchingStatus: DATA_FETCHING_STATUS.IN_PROGRESS, });

const { owner, repo } = this.props; const fetchPromise = githubService.fetchLatestPullRequest({ owner, repo }); const { promise, cancel } = makePromiseCancelable(fetchPromise);

this.cancelDataFetchingPromise = cancel;

promise .then((data) => { this.setState({ dataFetchingStatus: DATA_FETCHING_STATUS.SUCCESS, data }); }) .catch((error) => { if (error.isCanceled) return;

this.setState({ dataFetchingStatus: DATA_FETCHING_STATUS.FAILURE, data: null, }); }); }

componentWillUnmount() { this.cancelDataFetchingPromise(); }

render() { const { owner, repo, ...otherProps } = this.props; const { dataFetchingStatus, data } = this.state; return ( <OriginalComponent {...otherProps} dataFetchingStatus={dataFetchingStatus} data={data} /> ); } }

WithLatestGitHubPullRequest.displayName = \`WithLatestGitHubPullRequest(${getComponentDisplayName(OriginalComponent)})\`;

WithLatestGitHubPullRequest.propTypes = { owner: PropTypes.string.isRequired, repo:  PropTypes.string.isRequired, };

return WithLatestGitHubPullRequest; }


The original component gets thinner:

src/components/GitHubPullRequest/GitHubPullRequest.js

import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import moment from 'moment'; import DATA_FETCHING_STATUS from '@consts/dataFetchingStatus'; import './GitHubPullRequest.css';

const GitHubPullRequest = ({ dataFetchingStatus, data }) => ( <div className="c-latest-github-pull-request"> {dataFetchingStatus === DATA_FETCHING_STATUS.NOT_STARTED && ( <Fragment>Initializing...</Fragment>) } {dataFetchingStatus === DATA_FETCHING_STATUS.IN_PROGRESS && ( <Fragment>Fetching...</Fragment> )} {dataFetchingStatus === DATA_FETCHING_STATUS.FAILURE && ( <Fragment>Data fetching error...</Fragment> )} {dataFetchingStatus === DATA_FETCHING_STATUS.SUCCESS && ( <Fragment> <div className="c-latest-github-pull-request__title">{data.title}</div> <div className="c-latest-github-pull-request__body">{data.body}</div> <div className="c-latest-github-pull-request__created-at-and-user-login"> {moment(data.createdAt).calendar()} by {data.userLogin} </div> </Fragment> )} </div> );

GitHubPullRequest.propTypes = { dataFetchingStatus: PropTypes.oneOf( Object.values(DATA_FETCHING_STATUS) ).isRequired, data: PropTypes.shape({ title:     PropTypes.string.isRequired, body:      PropTypes.string.isRequired, userLogin: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired, }), };

export default GitHubPullRequest;


See that we also removed the word Latest from the component's name, as now it’s no longer coupled specifically with the latest pull request. It can now display any pull request data provided via props. The component's reusability was improved.

We moved DATA_FETCHING_STATUS to a separate file, as it’s now used in more than one place:

src/consts/dataFetchingStatus.js

export default { NOT_STARTED: Symbol('DATA_FETCHING_STATUS_NOT_STARTED'), IN_PROGRESS: Symbol('DATA_FETCHING_STATUS_IN_PROGRESS'), SUCCESS:     Symbol('DATA_FETCHING_STATUS_SUCCESS'), FAILURE:     Symbol('DATA_FETCHING_STATUS_FAILURE'), };


And the original component is now wrapped with:

src/containers/LatestGitHubPullRequest/LatestGitHubPullRequest.js

import GitHubPullRequest from '@components/GitHubPullRequest'; import withLatestGitHubPullRequest from '@hoc/withLatestGitHubPullRequest';

export default withLatestGitHubPullRequest(GitHubPullRequest);


Let's focus for a moment on the extracted withLatestGitHubPullRequest hoc. If it's the only component in the application that fetches data and tracks the fetching status, we could leave it in its current shape.

But let's say we have another component that fetches some other data. It can be a list of open React issues on GitHub, or any other information from any other service in the internet. Then both of these two components would probably contain the same status tracking code.

We see that withLatestGitHubPullRequest fetching status tracking is coupled to a specific data fetch, namely: githubService.fetchLatestPullRequest(...). The other component would track the status in the same way. The only difference would be the function that it calls to get the data.

Perhaps we could pass the data fetching function as an argument, thereby making the status tracking code independent from the actual data source?

src/hoc/withDataFetching.js

import React, { Component } from 'react'; import PropTypes from 'prop-types'; import getComponentDisplayName from '@hoc/utils/getComponentDisplayName'; import makePromiseCancelable from '@utils/makePromiseCancelable'; import DATA_FETCHING_STATUS from '@consts/dataFetchingStatus';

export default function withDataFetching(OriginalComponent) { class WithDataFetching extends Component { constructor(props) { super(props); this.state = { dataFetchingStatus: DATA_FETCHING_STATUS.NOT_STARTED, data: null, }; }

componentDidMount() { this.setState({ dataFetchingStatus: DATA_FETCHING_STATUS.IN_PROGRESS, });

const { fetchData } = this.props; const { promise, cancel } = makePromiseCancelable(fetchData()); this.cancelDataFetchingPromise = cancel;

promise .then((data) => { this.setState({ dataFetchingStatus: DATA_FETCHING_STATUS.SUCCESS, data }); }) .catch((error) => { if (error.isCanceled) return;

this.setState({ dataFetchingStatus: DATA_FETCHING_STATUS.FAILURE, data: null, }); }); }

componentWillUnmount() { this.cancelDataFetchingPromise(); }

render() { const { fetchData, ...otherProps } = this.props; const { dataFetchingStatus, data } = this.state; return ( <OriginalComponent {...otherProps} dataFetchingStatus={dataFetchingStatus} data={data} /> ); } }

WithDataFetching.displayName = \`WithDataFetching(${getComponentDisplayName(OriginalComponent)})\`;

WithDataFetching.propTypes = { fetchData: PropTypes.func.isRequired, };

return WithDataFetching; }


We extracted most of the code into withDataFetching hoc. It takes fetchData function via props.

The contract is that the function returns a promise. And that's it. The withDataFetching doesn't know what data are fetched and from where. The only responsibility it has is tracking when the fetching starts and whether it succeeds or fails. That means withDataFetching hoc is now reusable as it can supervise fetching from any data source.

Our withLatestGitHubPullRequest hoc is now responsible for building the fetchData function and passing it to the data fetching component.

src/hoc/withLatestGitHubPullRequest.js

import React from 'react'; import PropTypes from 'prop-types'; import getComponentDisplayName from '@hoc/utils/getComponentDisplayName'; import githubService from '@services/github';

export default function withLatestGitHubPullRequest(OriginalComponent) { const WithLatestGitHubPullRequest = function({ owner, repo, ...otherProps }) { const fetchData = function() { return githubService.fetchLatestPullRequest({ owner, repo }); }; return ( <OriginalComponent {...otherProps} fetchData={fetchData} /> ); }

WithLatestGitHubPullRequest.displayName = \`WithLatestGitHubPullRequest(${getComponentDisplayName(OriginalComponent)})\`;

WithLatestGitHubPullRequest.propTypes = { owner: PropTypes.string.isRequired, repo:  PropTypes.string.isRequired, };

return WithLatestGitHubPullRequest; }


And now we put it all together:

src/containers/LatestGitHubPullRequest/LatestGitHubPullRequest.js

import GitHubPullRequest from '@components/GitHubPullRequest'; import withDataFetching from '@hoc/withDataFetching'; import withLatestGitHubPullRequest from '@hoc/withLatestGitHubPullRequest';

export default withLatestGitHubPullRequest(withDataFetching(GitHubPullRequest));


Finally, let's return to the GitHubPullRequest component.

It now has two responsibilities:

  1. displaying the fetching status, and
  2. displaying the pull request information.

Let's relieve the component from the first responsibility.

We can do that by extracting the code to DataFetchingStatus component. Its only responsibility is displaying the status information:

src/components/DataFetchingStatus/DataFetchingStatus.js

import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; import DATA_FETCHING_STATUS from '@consts/dataFetchingStatus'; import './DataFetchingStatus.css';

const DataFetchingStatus = ({ dataFetchingStatus, data }) => ( <div className="c-data-fetching-status"> {dataFetchingStatus === DATA_FETCHING_STATUS.NOT_STARTED && ( <Fragment>Initializing...</Fragment>) } {dataFetchingStatus === DATA_FETCHING_STATUS.IN_PROGRESS && ( <Fragment>Fetching...</Fragment> )} {dataFetchingStatus === DATA_FETCHING_STATUS.FAILURE && ( <Fragment>Data fetching error...</Fragment> )} {dataFetchingStatus === DATA_FETCHING_STATUS.SUCCESS && ( <Fragment>Data fetched successfully.</Fragment> )} </div> );

DataFetchingStatus.propTypes = { dataFetchingStatus: PropTypes.oneOf( Object.values(DATA_FETCHING_STATUS) ).isRequired, };

export default DataFetchingStatus;


And GitHubPullRequest is now left with the responsibility to display the pull request information:

src/components/GitHubPullRequest/GitHubPullRequest.js

import React from 'react'; import PropTypes from 'prop-types'; import moment from 'moment'; import './GitHubPullRequest.css';

const GitHubPullRequest = ({ data: { title, body, userLogin, createdAt } }) => ( <div className="c-latest-github-pull-request"> <div className="c-latest-github-pull-request__title">{title}</div> <div className="c-latest-github-pull-request__body">{body}</div> <div className="c-latest-github-pull-request__created-at-and-user-login"> {moment(createdAt).calendar()} by {userLogin} </div> </div> );

GitHubPullRequest.propTypes = { data: PropTypes.shape({ title:     PropTypes.string.isRequired, body:      PropTypes.string.isRequired, userLogin: PropTypes.string.isRequired, createdAt: PropTypes.string.isRequired, }), };

export default GitHubPullRequest;


We could  extract the creation date formatting functionality even further, but for the purpose of this article we will stop here.

How do we make DataFetchingStatus and GitHubPullRequest work together? Let's use another hoc.

src/hoc/withDataFetchingStatus.js

import React from 'react'; import PropTypes from 'prop-types'; import getComponentDisplayName from '@hoc/utils/getComponentDisplayName'; import DATA_FETCHING_STATUS from '@consts/dataFetchingStatus';

export default function withDataFetchingStatus(DataFetchingStatus) { return function(OriginalComponent) { const WithDataFetchingStatus = function({ dataFetchingStatus, ...otherProps }) { return ( (dataFetchingStatus === DATA_FETCHING_STATUS.SUCCESS ? <OriginalComponent {...otherProps} /> : <DataFetchingStatus dataFetchingStatus={dataFetchingStatus} /> ) ); }

WithDataFetchingStatus.displayName = \`WithDataFetchingStatus(${getComponentDisplayName(OriginalComponent)})\`;

WithDataFetchingStatus.propTypes = { dataFetchingStatus: PropTypes.oneOf( Object.values(DATA_FETCHING_STATUS) ).isRequired, };

return WithDataFetchingStatus; }; }


src/containers/LatestGitHubPullRequest/LatestGitHubPullRequest.js

import GitHubPullRequest from '@components/GitHubPullRequest'; import DataFetchingStatus from '@components/DataFetchingStatus'; import withDataFetching from '@hoc/withDataFetching'; import withDataFetchingStatus from '@hoc/withDataFetchingStatus'; import withLatestGitHubPullRequest from '@hoc/withLatestGitHubPullRequest';

export default withLatestGitHubPullRequest( withDataFetching( withDataFetchingStatus(DataFetchingStatus)( GitHubPullRequest ) ) );


In this particular setup, the DataFetchingStatus won't have opportunity to render the information about success, because itself it’s not rendered in such scenario; the actual data is. Yet we've given DataFetchingStatus this functionality to make it a fully autonomous component, handling all the statuses.

That concludes the refactoring.

Summary

In this post, we split the React component that we started with in Part 1 further. We extracted separate parts responsible for:

  • deciding which data fetching functionality to use (withLatestGitHubPullRequest hoc),
  • managing the fetching process (withDataFetching hoc),
  • displaying the fetching status (DataFetchingStatus component),
  • displaying the pull request data (GitHubPullRequest component),
  • deciding whether to display the status or the data (withDataFetchingStatus hoc).

Along with previously extracted "github" service, these are now building blocks that comprise the functionality to display the latest React repo pull request.

Note that the total number of code lines is higher now than it was at the beginning. The main reasons behind that are the extra code that we now need to glue the parts together and the language syntax overhead.

Yet, the code blocks themselves are smaller and simpler. There are fewer execution paths inside each of them. And it's usually easier to understand the system when analysing one part at a time rather than everything at once.

Once the original fat React component was decomposed into parts, these parts could now be reused to extend the existing functionality with a minimal amount of new code required.

Having single-responsibility building blocks at our disposal, we can now easily select those that offer what is necessary for a particular new feature; none of them carry the baggage of unwanted functionality.

Encapsulating each responsibility in a separate function, class, or service gives us valuable separation of concerns. It's far easier to find a piece where a specific functionality is implemented, isolate buggy code, or simply replace an existing element with a new one.

The code is much more testable as well. A smaller number of code execution paths means there are fewer scenarios to test. Each part can be unit-tested separately so that we know our building blocks work properly.

All this makes the software more reliable. Any code changes and extensions are cheaper and quicker.

Conclusion

The three parts of this series showed us that React components don’t need to be fat. We just have to be aware of the responsibilities each piece of code has and then consciously decide whether we want to delegate them.

The Single Responsibility Principle can be applied to brand-new React components as well as to the existing fat ones through refactoring.

Have you got any questions about applying the Single Responsibility Principle in React applications? Reach out to me in comments and stay tuned for more useful content we plan to share on our blog.

Single Responsibility Principle in React Application series

The head picture has been taken by Russ Hendricks from flickr.

Jacek Mikrut - Front-end Developer

Jacek Mikrut

Front-end Developer

Jacek is a front-end developer. He earned a MSc degree in Computer Science from the Silesian University of Technology and has since worked as a full-stack developer for a number of companies, today focusing on front-end applications. Passionate about best practices in software development processes and opportunities offered by the newest web application technologies.

Tags

javascript
react

Share

Let's talk

Discover how software, data, and AI can accelerate your growth. Let's discuss your goals and find the best solutions to help you achieve them.

Hi there, we use cookies to provide you with an amazing experience on our site. If you continue without changing the settings, we’ll assume that you’re happy to receive all cookies on Sunscrapers website. You can change your cookie settings at any time.