This document aims to give general guidelines of how code is written and structured in the Cloud Manager project.
At this point in the project, we prefer the use of function components using hooks for state management. It is not necessary to rewrite existing class components as function components, and it is still fine to write a class component if that works better for your situation.
export const Component: React.FC<Props> = props => {
const [count, setCount] = React.useState < number > 0;
return <div>{count}</div>;
};
export default React.memo(Component);
You should include React.memo() as in the example if you think the component will be included
in larger layouts where frequent re-renders are common. Note also that we generally export
the "raw" component (export const Component...
) as well as the "wrapped" component (export default React.memo(Component)
). This is often helpful for testing, when you don't want to
test the containers, wrappers, HOCs, or whatever that the base component is using.
When using the component in the app, import the default export:
import Component from "./src/Component";
export class Component extends React.Component<CombinedProps, State> {
state: State = {
count: 0
};
render() {
return <div>{count}</div>;
}
}
export default Component;
Default and raw exports are the same as in function components. If you want the equivalent
of React.memo
, use a PureComponent:
export class Component extends React.PureComponent<CombinedProps, State> {
...
Even with hooks, we still have components that require multiple wrappers (HOCs).
To combine multiple HOCs into a single wrapper, we use a helper called compose
from
the recompose
library. (This is a functional programming concept called function
composition, hence the name).
import { compose } from 'recompose';
interface Props {...}
type CombinedProps = Props & HOC1Props & HOC2Props & HOC3Props;
export const RawComponent: React.FC<CombinedProps> = props => {
...
}
const enhanced = compose<CombinedProps, Props>(
hoc1,
hoc2,
hoc3
);
export default enhanced(RawComponent);
Most HOCs pass extra props to the raw component; when using Redux's connect
, for example,
the data you select from the Redux store is available as props in the connected component.
CombinedProps
is our convention for the final set of props that a component
will have once it is wrapped in all its HOCs. Note that the component itself is typed
as expecting CombinedProps
, and the compose
function generally takes <CombinedProps, Props>
.
This is backward from intuition (a holdover from functional programming), but tells TypeScript
that what is being passed into compose
is a component that expects Props
and what comes
out the other end of compose
is a component that has CombinedProps
.
As with everything else in React-world, MUI's styling solution now supports hooks.
We prefer this pattern as it is terser, makes testing easier, and avoids cluttering up the component
tree with withStyles(Component)
.
import { makeStyles, Theme } from "src/components/core/styles";
const useStyles = makeStyles((theme: Theme) => ({
message: {
fontSize: "16px",
color: theme.color.red
}
}));
Inside the component, access these classes by calling useStyles()
:
const Component: React.FC<{}> = props => {
const classes = useStyles();
return <div className={classes.message}>A message for you</div>;
};
- Styles for light mode are located in
packages/manager/src/themeFactory.ts
- Styles for dark mode are located in
packages/manager/src/themes.ts
- Utility classes (based off of Tailwind CSS naming conventions) are located in
packages/manager/src/index.css
Every time an instance of a component is created so are all the instance methods, just like renderContent in the following code. So this takes more CPU to create, more memory to store, and more CPU to tear down. This code smells because there's a function invocation that has no arguments. That screams side-effects. To correct this we simply extract the functionality into a new component passing the props as necessary.
Before
class MyComponent extends Component {
renderContent = () => {
const { error, loading, data } = this.props;
if (error) {
return this.renderError();
}
if (loading) {
return this.renderLoading();
}
if (!data || data.length === 0) {
return this.renderEmptyState();
}
return this.renderData();
};
render() {
<div>{this.renderContent()}</div>;
}
}
After
const MyComponent = (props) => {
const { loading, error, data } = props;
return (
<div>
<ComponentContent loading={loading} error={error} data={data}/>
</div>
);
}
const ComponentContent = (props) => {
const { loading, error, data } = props;
if (error) {
return <ErrorComponent error={error}>
}
if (loading) {
return <LoadingComponent />
}
if (!data || data.length === 0) {
return <EmptyStateComponent >
}
return <DataComponent data={data} />
}
const ErrorContent = (props) => {
const { error } = props;
return (...);
}
const LoadingComponent = (props) => {
return (...);
}
const EmptyStateComponent = (props) => {
return (...);
}
const DataComponent = (props) => {
const { data } = props;
return (...);
}
So now we have two separate and testable components. We've also done the type checking so inside the DataComponent, we don't have to worry about if there's no data or an empty array, we can just work with what we're provided!
Abstracting code is a great way to not repeat yourself and keep the code DRY. It's not a huge deal if you need to repeat some code twice, but more than that, whatever logic you write should be abstracted into either it's own file or outside of the Class/function. Here are some hard, fast rules when it comes to abstracting:
- Any functions that are inside a React Class/function that don't rely on state or props should be abstracted out
Bad
class MyComponent extends React.PureComponent<MyProps> {
/** no reason for this to be attached to the Class */
filterOutNumbers = (arrayOfNumbers: number[]) => {
return arrayOfNumbers.filter(eachNumber => eachNumber > 10)
}
return <div />
}
Good
class MyComponent extends React.PureComponent<MyProps> {
return <div />
}
/** this can now be unit tested separately */
const filterOutNumbers = (arrayOfNumbers: number[]) => {
return arrayOfNumbers.filter(eachNumber => eachNumber > 10)
}
- Any logic that is being duplicated or even used more than twice should live in its own file (ideally in the
/utilities
dir)
Bad
class MyComponent extends React.PureComponent<MyProps> {
return (
<h1>
{
this.props.title
.split(' ')
.map(capitalize)
.join(' ')
}
</h1>
<p>
{
this.props.subtitle
.split(' ')
.map(capitalize)
.join(' ')
}
</p>
)
}
Good
import { capitalizeAllWords } from 'src/utilities/word-formatting-utils'
class MyComponent extends React.PureComponent<MyProps> {
return (
<h1>{capitalizeAllWords(this.props.title)}</h1>
<p>{capitalizeAllWords(this.props.subtitle)}</p>
)
}
- Try your best to abstract even components imported from external libraries
We're creating abstractions of all external components, even if that's just an immediate exporting
of the component export { default } from '@material-ui/core'
. We're doing this for the following
reasons:
- Provides a common entry point where can make site-wide changes to the components structure or functionality.
- Allows us control of the API we consume, regardless of where the component comes from.
- The wrapper component allows us to respond to naming/file structure changes made by the component authors.
As a rule, always use absolute paths for module imports. Something that looks similar to
import MyComponent from "src/components/MyComponent";
is much better than
import MyComponent from "../../../MyComponent";
This project relies on a number of third-party dependencies. It is important that when importing those dependencies you import only the necessary files. For example, if I needed to create an Observable using RxJS I would import only Observable and the type of Observable I want to create. This keeps bundle size down substantially.
Bad
import { Observable } from "rxjs/Rx";
Good
import "rxjs/add/observable/of";
import { Observable } from "rxjs/Observable";
Components making API requests generally want to work with an array of Linode API errors. These have the shape:
{ field: 'field-name', reason: 'why this error occurred' }
We have added an interceptor to our Axios instance that essentially guarantees that any
error from an API function will have this shape. For example, if you block network requests
using devtools, there is no response from the API. But if you .catch()
this error,
you'll find that it has the above shape, with a default message ("An unexpected error occurred.")
This makes it easy to work with errors, but the default message is not very situation specific.
Often, what we want is to use a real error message from the API if it is available, and use
a situation-specific fallback message otherwise. We have a helper in our utilities directory
for this called getAPIErrorOrDefault
.
import { getAPIErrorOrDefault } from "src/utilities/errorUtils";
apiRequest().catch(error => {
const apiError = getAPIErrorOrDefault(
error, // If this is an array of API field errors, it will be returned unchanged.
"Your Linode is hopelessly broken.", // If no field errors are present, an array consisting of an error with this reason is returned.
"linode-id" // Optional. If you want the default field error to have a `field` property, this argument will be used.
);
});
A common pattern is to wrap an API response in this method, then use the first reason as the error message. This isn't ideal since it's possible multiple errors could be returned from the same request, but it is used in many places throughout the app.
makeApiRequest()
.then(doSomethingOnSuccess)
.catch(errorResponse => {
const fallBackMessage = "Oh no, something horrible happened!";
const errorMessage = getAPIErrorOrDefault(errorResponse, fallbackMessage)[0]
.reason;
});
Given the setup of our app, errorMessage
will be a string containing either a legit message
from the API or else the fallBackMessage
specified.
The usual pattern is to map field errors to the appropriate field, showing a generalError for any errors that don't have a field. For example,
a form might have an input for region
, and that element will display any errors with { field: 'region', reason: 'whatever' }
inline. In
some cases, however, we either aren't checking for every possible error field, or we aren't entirely sure what all of the possible fields the
API is considering are. To make sure that we catch these and show them to the user, use the getErrorMap
helper:
import { getErrorMap } from "src/utilities/errorUtils";
apiRequest().catch(error => {
const errorMap = getErrorMap(
["label", "region"], // Fields we want to check for
error
);
const labelError = errorMap.label;
const regionError = errorMap.region;
const generalError = errorMap.none;
});
errorMap
will be an object with one key for each of the fields we specified, and a none
key that captures any errors (the first
one it finds) that don't match the provided fields:
console.log(errorMap);
{
label: 'a label error',
region: 'a region error',
none: 'a linode_id error or similar'
}
The first step to creating a paginated list is asking yourself one question:
Is my data sourced from Redux state or is it being requested on componentDidMount
?
If your data is being sourced from Redux state, it's safe to assume that the data is being pre-loaded on app mount. But not just some of the data - ALL of the data. A pattern we have adopted is to request every single page of entities for data that we are storing in Redux. Because of this, there is a possibility that there may be more than 100 items in the slice of Redux state. Which leads us to the first solution...
The first step in paginating things from Redux is to source the data
import { APIError } from "@linode/api-v4/lib/types";
import { Volume } from "@linode/api-v4/lib/volumes";
import { connect } from "react-redux";
interface ReduxStateProps {
loading: boolean;
error?: APIError[];
data: Volume[];
}
const MyComponent: React.FC<ReduxStateProps> = props => {
return <div />;
};
const mapStateToProps: MapStateToProps<
/* props that this HOC returns */
ReduxStateProps,
/* other props this HOC is inheriting */
{},
/* global redux state */
ApplicationState
> = state => ({
loading: state.__resources.volumes.loading,
error: state.__resources.volumes.error,
volumes: state.__resources.volumes.items
});
export default connected(mapStateToProps)(MyComponent);
Next, we need to leverage the <Paginate />
render props Component, which has built-in pagination logic, so you don't have to worry about the heavy lifting
const MyComponent: React.FC<ReduxStateProps> = props => {
return (
<Paginate data={data} pageSize={25}>
{({
data: paginatedData,
count,
handlePageChange,
handlePageSizeChange,
page,
pageSize
}) => <div />}
</Paginate>
);
};
Now we see that we have access to all the data, the current page, the page size, and helper functions to change page and page size.
Finally, lets put it all together now with our <PaginationFooter />
component
const MyComponent: React.FC<ReduxStateProps> = props => {
return (
<Paginate data={data} pageSize={25}>
{({
data: paginatedData,
count,
handlePageChange,
handlePageSizeChange,
page,
pageSize
}) => (
<React.Fragment>
<Table aria-label="List of your Volumes">
<TableHeader>My Volumes</TableHeader>
<TableBody>
{paginatedData.map(eachVolumes => {
return (
<TableRow>
<TableCell>{eachVolume.label}</TableCell>
<TableCell>{eachVolume.size}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<PaginationFooter
count={count}
page={page}
pageSize={pageSize}
handlePageChange={handlePageChange}
handleSizeChange={handlePageSizeChange}
/>
</React.Fragment>
)}
</Paginate>
);
};
Paginating data this way is similar to the last approach with some differences. In this case, we'll be leveraging the <Pagey />
higher-order component.
The first step is to wrap your base component in the HOC and tell Pagey what request you want to fire when the page changes:
import { Pagey, PaginationProps } from "src/components/Pagey";
import { getInvoices } from "@linode/api-v4/lib/account";
interface OtherProps {
someText: string;
}
const MyComponent: React.FC<PaginationProps & OtherProps> = props => {
return <div />;
};
const paginated = Pagey((ownProps: OtherProps, params: any, filter: any) => {
return getInvoices(params, filter);
});
export default paginated(MyComponent);
Now we have access to the same props as before. Lets add in the rest of our markup
const MyComponent: React.FC<PaginationProps & OtherProps> = props => {
const {
data: myInvoices,
page,
pageSize,
loading,
error,
count,
handlePageChange,
handlePageSizeChange
} = props;
return (
<React.Fragment>
<Table aria-label="List of your Invoices">
<TableHeader>My Invoices</TableHeader>
<TableBody>
{myInvoices.map(eachInvoice => {
return (
<TableRow>
<TableCell>{eachInvoice.label}</TableCell>
<TableCell>{eachInvoice.amount}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<PaginationFooter
count={count}
page={page}
pageSize={pageSize}
handlePageChange={handlePageChange}
handleSizeChange={handlePageSizeChange}
/>
</React.Fragment>
);
};
Now, each time you change a page, the appropriate page will be requested. <Pagey />
also gives you access to a bunch of other props that might help for other tasks you're attempting to accomplish, namely:
Prop Name | Type | Description |
---|---|---|
onDelete | () => void | Helper function that should be invoked when you're deleting items from the list. This will ensure no redundant requests are made |
order | 'asc' or 'desc' | What order in which the data is being sorted. |
handleSearch | (filter?: any) => void | Helper function to re-invoke the base request with new filters |
searching | boolean | is the handleSeach Promise in-progress |
handleOrderChange | (sortBy: string, order: 'asc' or 'desc' = 'asc', page: number = 1) => void | Helper function to change the sort and sort order of the base request |
isSorting | boolean | is the handleOrderChange Promise in-progress |
Showing messaging to users is a complex task that varies depending on whether an action is immediate or scheduled to happen sometime in the future. For all actions where we cannot predict their completion time, we use toasts or snackbar messages.
We're leveraging notistack for all toasts, which is an abstracted HOC built upon material-ui's Snackbar. All MUI's props can be applied to the Snackbar as well.
An example of how to use a Toast is as follows:
import React from "react";
import { InjectedNotistackProps, withSnackbar } from "notistack";
interface Props extends InjectedNotistackProps {
/**
* props here
*/
}
export const Example: React.FC<Props> = props => {
const handleClick = () => {
props.enqueueSnackbar("this is a toast notification", {
onClick: () => alert("you clicked the toast!")
});
};
return <div onClick={handleClick}>Click Me</div>;
};
export default withSnackbar(Example);