ES module is a .js
file with at least one export or folder containing index.js
with at least one export.
The guiding module structure design principle is: you should imagine that any module might be extracted to its own package some day.
In index.js
we define a module interface, its exported object. This file should not contain business logic.
Placing business logic in index.js
leads to following inconveniences, making code reading harder:
- If
components/Compo/index.js
is opened in a tab in IDE, in many of them name of the tab would be "index.js", which is non-descriptive. There are addons in most IDEs that allow to show "Compo/index.js" instead of just "index.js" in tab name, but "Compo.js" is still more succinct yet descriptive. - TODO: example with quick opening
main.js
by filename
First, file paths in MacOS and Windows are case-insesitive. Developer on MacOS could misspell a component module named SignUpForm
as components/SignupForm/
then import {SignUpForm} from '@app/components'
and not to get an error. After changes are pushed to the CI, it will crash with an error if being run on GNU/Linux, where file paths are case-sensitive.
Second, see the guiding principle. Package name cannot contain uppercase letters.
Therefore, camelCase should never be used. This leaves snake_case and kebab-case. The latter is by far the most common convention today. The only use of underscores is for internal node packages, and this is simply a convention from the early days.
components/ — exports presentational components; has no default export
containers/ — exports container components; has no default export
store/ — exports the store object by default
api/ — exports the api object by default
utils/ — export utility functions; has no default export
style/ — exports fonts, colors, dimensions objects; has no default export
fonts — exports fonts object by default
colors — exports colors object by default
dimensions — exports dimensions object by default
i18n/ — exports an object with translated messages by locale by default
main — exports a reference to the mounted app by default
The main field is a module ID that is the primary entry point to the program. That is, if a package is named foo, and a user installs it, and then does require("foo"), then the main module’s exports object will be returned.
We define it in order to facilitate publishing of the app in private NPM repo for embedding in another app.
This way we import modules of the app as if we had them published in a scoped NPM package. Later we could publish that package in a private NPM repo. This supports the guiding principle and facilitates the code reuse across apps being deleloped by an organisation.
- Break the app into components Visual components often map tightly to their respective React components.
- Build a static version of the app Start off components without using state. Instead, make them pass static props down.
- Determine what should be stateful In order for the app to become interactive, user should be able to modify properties of components. Components have to be mutable and therefore stateful.
- Determine in which component each piece of state should live Follow the approach described in Thinking in React.
- Hard-code initial state Rewrite components to use this.state. Seed it from mocked collection.
- Add inverse data flow Define action handlers and pass them down to components that emit events.
- Add server communication
/* /components/index.js */
export { default as Button } from './Button'
export { default as Link } from './Link'
Components should be contained in components
and containers
depending on their types.
There are should be no nested component modules. It obstructs code reuse and reasoning about the code.
Let's suppose app contains two components that have nested components with the same name SubCompo
. If developer reads JSX of a top-level one and faces <SubCompo />
, he cannot immediately know which one of the subcomponents is being referenced. To know it, developer needs to scroll a code editor buffer up to the import section or distract from reading the code by raising up his eyes on it.
There is a case when a single module can contain several components:
import { FirstStep, SecondStep, ThirdStep } from './'
class ThreeStepDialog extends React.Component {
state = { stepNum: 1 }
stepBack = () => this.setState(({ stepNum }) => ({ stepNum: stepNum - 1}))
stepForward = () => this.setState(({ stepNum }) => ({ stepNum: stepNum + 1}))
render() {
switch (this.state.stepNum) {
case 1: return <FirstStep stepForward={this.stepForward} />
case 2: return <SecondStep stepForward={this.stepForward} stepBack={this.stepBack} />
case 3: return <ThirdStep stepBack={this.stepBack} />
}
}
}
If the step components are only used in the dialog, it's allowed to keep them in the same module.
components/
ThreeStepDialog/
ThreeStepDialog.js
FirstStep.js
SecondStep.js
ThirdStep.js
index.js
/* Button.js */
import React from 'react'
export default class Button extends React.Component { }
/* SignInFormContainer.js */
import { connect } from 'react-redux'
import { SignInForm } from 'components'
const SignInFormContainer = connect()(SignInForm)
export default SignInFormContainer
Component name reflects its responsibility.
The naming scheme is [Prefix]Entity[Postfix]
, e.g. User
, FormGroup
, ActiveUserList
.
All name parts are in PascalCase; Entity
cannot be empty.
Prefix
specifies a component’s distinctive quality, e.g. Active
, Main
, Bottom
, Small
.
Entity
is a business domain entity or a visual component being rendered, e.g. User
, Magazine
, Breadcrumb
, Card
, Button
.
Postfix
specifies a meta-entity, e.g. List
, Dashboard
, Group
, Container
.
Component name should be in the single form. If the responsibility of a component is to display a list of entities, name it EntityList
instead of Entities
. This facilitates adherence to the SRP: developer is provoked to extract Entity
as a component which responsibility is to render an item of the list.
Container component name has postfix Container
.
Typical component name prefixes:
- Filterable
- Editable
- Toggleable
Typical component name postfixes:
- List
- Form
- Dashboard
A prop name reflects its type.
For a function that is not event handler, it starts with a verb, e.g. fetchUsers
, deleteEntity
.
Typical examples: getSubscriptionsCallback()
(incorrect) — getSubscriptions()
(correct).
For event handler, it starts with on
, e.g. onUserLoggedOut
, onUserAddButtonClick
.
For a boolean, it starts with is
, are
, has
, have
, should
, e.g. isLoading
, isOpen
, areItemsLoaded
, shouldUpdateUserProfile
, hasBouncer
, haveBeenTransformationsApplied
.
Id of an instance of particular entity should be named as entityId
not just id
.
Even in this case: <User userId={1} />
. Why couldn't we parameterize User
with just id
? Isn't it obvious that the id is user id? No, it's not: it can be confused with id of DOM element, for example. Moreover, if we decide to add an id of other entity as a prop, we could leave userId
unrenamed, thus eliminating additional refactoring chore.
Prop name should reflect its purpose exactly, not being too verbose.
Typical examples: <Modal closeModal={...} />
(incorrect) — <Modal close={...}>
(correct).
The scheme is analogous to the one for property names.
Action handler name should start with handle
, e.g. handleSubmit
, handleUserAddButtonClick
.
Render helper name should start with render
, e.g. renderListItem
.
children
should have type PropTypes.node
, and not
PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
PropTypes.string
])
or something like that.
PropTypes.node
represents anything that can be rendered: numbers, strings, elements or an array (or fragment) containing these types.
Do not use jsx
. It was necessary in 2013, when JSX was compiled by react-tools
. Now JS is compiled by Babel which supports JSX natively and there is no need in jsx
extension.
If a datapoint needed to display a visual component can be derived from existing ones, it should be.
A practical example:
class SellModal extends Component {
constructor(props) {
super(props);
this.state = {
isAcceptError: false,
priceError: false,
price: 1,
isAccept: false,
minPriceError: false,
};
this.handleInputChange = this.handleInputChange.bind(this);
this.handleSaleClickBtn = this.handleSaleClickBtn.bind(this);
}
handleInputChange(event) {
const target = event.target;
const value = target.type === 'checkbox'
? target.checked
: +target.value || 1;
const name = target.name;
this.setState({
[name]: value,
[`${name}Error`]: false,
});
}
handleSaleClickBtn() {
if(!this.state.isAccept) {
this.setState({
isAcceptError: true,
})
return;
}
if(!this.state.price) {
this.setState({
priceError: true,
})
return;
}
if (+this.state.price < 1) {
this.setState({
minPriceError: true,
})
}
const subscription_id = this.props.id;
const resale_price = this.state.price * 100;
addPlacedSubscription(subscription_id, resale_price)
.then(data => {
this.props.getSubscriptionsCallback();
this.props.closeModal()
})
.catch(error => {
console.log('addPlacedSubscription error',error)
this.props.closeModal();
})
}
render() {
const {
name,
id,
} = this.props;
const {
priceError,
price,
isAccept,
isAcceptError,
minPriceError,
} = this.state;
return (
<div>
<div className="m_header">
{`Are you sure you want to sell ${name}s subscription?`}
</div>
<div>
<label className="m_check_wrap">
<input
className="m_check"
name="isAccept"
type="checkbox"
onChange={this.handleInputChange}
checked={isAccept}
/>
Yes, I'm sure that I want to sell this subscription
</label>
{isAcceptError && (
<div className="m_error">select to continue</div>
)}
</div>
<div className="m_price">
<div className="m_set-price">
Set price for which you want to sell this subscription ($)
</div>
<input
name="price"
min="1"
className={priceError || minPriceError ? "m_text_error" : "m_text"}
type="number"
placeholder="Set price here"
value={price}
onChange={this.handleInputChange}
/>
{priceError && (
<div className="m_error">Empty line</div>
)}
<div className="m_comission">
The commission for the sale is 3% of the payment amount
</div>
</div>
<div className="unsub_modal_btn" onClick={this.handleSaleClickBtn}>
sale
</div>
</div>
);
}
}
Data that is needed to be persisted between rendering passes but does not influence result of rendering, should be stored as a ref (in case of function component) or instance property (in case of class component). Antipattern:
class extends React.Component {
state = {
...
fcmToken: null // Firebase cloud messaging token
}
async componentDidMount() {
const isPermissionGranted = await firebase.messaging().hasPermission();
if (isPermissionGranted) {
const fcmToken = await firebase.messaging().getToken()
this.setState({ fcmToken })
}
}
handleSignUpButtonPress = () => {
const {email, fcmToken} = this.state
this.props.signUp({email, fcmToken})
}
}
TODO: console.log vs debugger
TODO: error handling using error boundaries vs catching in components and promises Antipattern:
firebase.notifications()
.displayNotification(localNotification)
.catch(err => console.error(err));
https://reactjs.org/docs/error-boundaries.html facebook/react#11334
Antipattern:
errorStyle: { ... }
Antipattern:
<Text style={[styles.confirmCodeDesc, {textAlign: 'center'}]}>
The sign of good design: store
module could be shared between web and mobile apps without modification.
Thunks, sagas or epics should not refer history
or navigation
objects. Instead, manage it on component level.
store/ — exports the store object by default
actions/ — exports Redux async action creators; has no default export
reducers/ — exports Redux reducers; exports the root reducer by default
rootReducer.js
index.js
sagas/ — (if redux-saga is used) exports Redux sagas; exports the root saga by default
rootSaga.js
index.js
epics/ — (if redux-observable or redux-most is used) exports Redux epics; exports the root epic by default
store.js
index.js
(without support for SSR)
import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction'
import rootReducer from './reducers'
import rootSaga from './sagas';
const sagaMiddleware = createSagaMiddleware()
const store = createStore(
rootReducer,
composeWithDevtools({})(
applyMiddleware(sagaMiddleware)
)
)
sagaMiddleware.run(rootSaga)
export default store
export { default as auth } from './auth'
export { default as entities } from './entities'
export { default } from './rootReducer'
import { combineReducers } from 'redux'
import * as reducers from './'
const rootReducer = combineReducers(reducers)
export default rootReducer
export { default } from './rootSaga'
export default function* rootSaga() { }
It's methods correspond to API actions. E.g., logIn
, fetchEntities
, createEntity
.
api/
mixins/
withAuth.js
index.js
api.js
index.js
import { compose } from 'lodash/fp'
import mixins from './mixins'
const enhanceWithMixins = compose(...mixins)
class Api {
baseUrl = 'https://example.com'
makeUrl(path) {
return `${this.baseUrl}${path}`
}
async request(path, options) {
const response = await fetch(this.makeUrl(path), {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
...options
})
const json = await response.json()
return response.ok ? json : Promise.reject(json)
}
get(path, options) {
return this.request(path, { method: 'get', ...options })
}
post(path, body) {
return this.request(path, { method: 'post', body: JSON.stringify(body) })
}
put(path, options) {
return this.request(path, { method: 'put', ...options })
}
delete(path, options) {
return this.request(path, { method: 'delete', ...options })
}
}
export default new (enhanceWithMixins(Api))()
import withAuth from './withAuth'
/* export an iterable to spread it out in importing module */
export default [withAuth]
import routes, { makeUri } from 'api/routes'
import snakeCaseKeys from 'snakecase-keys'
export default function withAuth(superclass) {
return class extends superclass {
signIn(credentials) {
return this.post(makeUri(routes.login), credentials)
}
signOut() {
return this.post(makeUri(routes.logout))
}
signUp(data) {
return this.post(makeUri(routes.register), snakeCaseKeys(data))
}
verifyEmail(userId, data, getParams) {
return this.get(makeUri(routes['verification.verify'], { id: userId }, getParams))
}
resendEmailVerificationLink(data) {
return this.get(makeUri(routes['verification.resend']), snakeCaseKeys(data))
}
resetPassword(data) {
return this.post(makeUri(routes['password.reset']), snakeCaseKeys(data))
}
requestResetPasswordLink(email) {
return this.post(makeUri(routes['password.sendResetLinkEmail']), email)
}
changeCredentials(newCredentials) {
return this.post('/users/me/change-credentials', newCredentials)
}
}
}
API method name starts with a verb. There are a few idiomatic verbs:
fetch
In order to fetch entity from server, we call corresponding methodfetchEntity
.create
update
delete
The main principle of naming things: statements involving the named identifier should be easy to understand. Difficulty of reading the code originates from necessity of storing a context (identifiers from the scope) in short term memory. The effect of each statement should be understandable without referencing other statements. Signs of good named code:
- Statements are read like written in a natural language.
Examples:
Good:
isSidebarVisible ? ... : ...
Bad:showSidebar ? ... : ...
Good:fetchEntities()
Bad:getEntitiesRequest()
Good:PaymentConfirmationScreen
Bad:PayConfirm
To decide if a chosen name is good, try to read statements involving it isolated from the context. If you immediately understand what the effect of each statement is, then the name is good.
An action is expressed by a verb in English.
It lets you write code that is less readable. There is no use-cases where it has advantages over let
. Use let
instead.
Remove console.log()
. For debugging, use debugger
statement or set breakpoint in IDE.
The motivation for this is a mixture of keeping the project root clean and keeping static configuration all in one place. This approach becomes more common in JS community with time.
They should be configured in package.json
:
"devDependencies": {
"husky": "^1.2.0"
},
"husky": {
"hooks": {
"pre-commit": "eslint . && jest --passWithNoTests --lastCommit",
"pre-push": "yarn build"
}
}
.env
.env.local
.editorconfig
.nvmrc
.gitignore