├── .gitignore
├── Makefile
├── README.md
├── data
├── de.json
├── fr.json
└── gb.json
├── doc
└── article.md
├── package.json
└── src
├── country-chooser.js
├── country.js
├── index.html
├── main.js
├── old-async-country-chooser.js
├── old-async-country.js
├── promised.js
└── style.css
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | out/
3 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | PATH := node_modules/.bin:$(PATH)
2 | SHELL := /bin/bash
3 |
4 | # Scripts
5 | js_source_files := $(wildcard src/*.js)
6 | app_bundle := out/dist/main.js
7 |
8 | lib_flags = node_modules/flag-icon-css
9 |
10 | html_source_files := $(wildcard src/*.html)
11 | css_source_files := $(wildcard src/*.css)
12 | flags_css_files = $(lib_flags)/css/flag-icon.css
13 | flags_image_files := $(wildcard $(lib_flags)/flags/*/*.svg)
14 | json_files := $(wildcard data/*.json)
15 |
16 | built_files = $(app_bundle) \
17 | $(html_source_files:src/%.html=out/dist/%.html) \
18 | $(css_source_files:src/%.css=out/dist/%.css) \
19 | $(flags_css_files:$(lib_flags)/css/%=out/dist/flags/%) \
20 | $(flags_image_files:$(lib_flags)/flags/%=out/dist/flags/%) \
21 | $(json_files:data/%=out/dist/data/%) \
22 | out/dist/data/countries.json
23 |
24 | npm_bin := $(abspath node_modules/.bin)
25 | browserify = $(npm_bin)/browserify
26 |
27 | port?=8000
28 |
29 | define copy
30 | @mkdir -p $(dir $@)
31 | cp $< $@
32 | endef
33 |
34 | .PHONY: all clean available
35 |
36 | all: $(built_files)
37 |
38 | $(app_bundle): $(js_source_files) package.json
39 | @mkdir -p $(dir $@)
40 | $(browserify) src/main.js --standalone main -t [ babelify ] -o $@
41 |
42 | out/dist/%.html: src/%.html
43 | $(copy)
44 |
45 | out/dist/%.css: src/%.css
46 | $(copy)
47 |
48 | out/dist/flags/%.css: node_modules/flag-icon-css/css/%.css
49 | $(copy)
50 |
51 | out/dist/flags/%.svg: $(lib_flags)/flags/%.svg
52 | $(copy)
53 |
54 | out/dist/data/%.json: data/%.json
55 | $(copy)
56 |
57 | out/dist/data/countries.json: $(json_files)
58 | jq --slurp . $^ > $@
59 |
60 | available: $(build_files)
61 | (cd out/dist && $(npm_bin)/http-server -p $(port) && --cors)
62 |
63 | clean:
64 | rm -rf out/tmp out/dist out/reports
65 |
66 | tmp:
67 | @echo $(built_files)
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | A demonstration of Higher-Order React components
2 | ================================================
3 |
4 | The components Country and CountryChooser display data synchronously -- it is passed to their props.
5 |
6 | The higher order component Promised decorates a component class to load props asynchronously from a promise.
7 |
8 | These are combined in the main.js entry point to load country information from HTTP+JSON APIs.
9 |
10 |
11 | Building
12 | --------
13 |
14 | Prerequisites:
15 |
16 | * node and npm
17 | * Gnu Make & the standard Unix command-line utilities
18 | * jq
19 |
20 | On MacOS X these can all be installed with Homebrew.
21 |
22 |
23 | To build:
24 |
25 | % npm install
26 | % make
27 |
28 | To run:
29 |
30 | Execute the command:
31 |
32 | % make available
33 |
34 | Then open [http://localhost:8000/](http://localhost:8000/) in a modern web browser.
35 |
36 |
--------------------------------------------------------------------------------
/data/de.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Germany",
3 | "iso": "de"
4 | }
--------------------------------------------------------------------------------
/data/fr.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "France",
3 | "iso": "fr"
4 | }
--------------------------------------------------------------------------------
/data/gb.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "United Kingdom",
3 | "iso": "gb"
4 | }
--------------------------------------------------------------------------------
/doc/article.md:
--------------------------------------------------------------------------------
1 | Higher Order React Components
2 | =============================
3 |
4 | When writing user interfaces with the React framework, I often find that several of my components have similar behaviour. For example, I may have different components that display the eventual value of a [promise][], or display changing values of an [Rx event stream], are sources or targets for drag-and-drop interactions, and so on. I want to define these common behaviours once and compose them into my component classes where required. This, in a nutshell, is what "higher-order components" let me do.
5 |
6 | Higher-order components are a superior alternative to JavaScript prototypes, [ES6 class inheritance][] and [React mixins][]. Prototypes and ES6 class inheritance (which are the same thing under the hood) are limited to single inheritance, so a component class cannot inherit behaviour from multiple superclasses. React mixins are not supported in ES6 classes and look likely to be dropped from the React API in a future release. Higher-order components, on the other hand, let you compose multiple behaviours into a component class, even if written as ES6 class. They can also be used with components written with the old ES5 syntax.
7 |
8 |
9 | An Example Use Case for Higher-Order Components
10 | -----------------------------------------------
11 |
12 | Imagine we’re writing an international e-commerce site. Each customer has a preferred country that the site uses to calculate shipping costs and determine the currency in which to display prices. The site displays the customer’s preferred country in the navigation bar at the top of each page. If the user is travelling, they can select their preferred country from a menu of countries supported by the site.
13 |
14 | Both the country associated with the user’s session and the list of all countries supported by the application are fetched by HTTP from the server in JSON format and displayed by React components. For example, the user's preferred country is returned as:
15 |
16 | ~~~~~~~~~~~~~~~json
17 | {"iso": "GB", "name": "United Kingdom"}
18 | ~~~~~~~~~~~~~~~
19 |
20 | And the list of supported countries is returned as:
21 |
22 | ~~~~~~~~~~~~~~~json
23 | [
24 | {"iso": "FR", "name": "France"},
25 | {"iso": "GB", "name": "United Kingdom"},
26 | ...
27 | ]
28 | ~~~~~~~~~~~~~~~
29 |
30 | The Country component below displays the user’s preferred country. Because country data is received asynchronously, it receives a *promise* of the country information. While the promise is pending, the component displays a loading indicator. When the promise is resolved successfully, the component displays the country information as a flag icon and name. If the promise is rejected, the component displays an error message.
31 |
32 | ~~~~~~~~~~~~~~~~~~~javascript
33 | class Country extends React.Component {
34 | constructor(props) {
35 | super(props);
36 | this.state = {loading: true, error: null, country: null};
37 | }
38 |
39 | render() {
40 | if (this.state.loading) {
41 | return Loading...;
42 | }
43 | else if (this.state.error !== null) {
44 | return Error: {this.state.error.message};
45 | }
46 | else {
47 | var iso = this.state.country.iso;
48 | var name = this.state.country.name;
49 |
50 | return (
51 |
52 |
53 | {name}
54 |
55 | );
56 | }
57 | }
58 |
59 | componentDidMount() {
60 | this.props.promise.then(
61 | value => this.setState({loading: false, country: value}),
62 | error => this.setState({loading: false, error: error}));
63 | }
64 | }
65 | ~~~~~~~~~~~~~~~~~~~
66 |
67 | It can be used like this (assuming fetchJson loads JSON from a relative URL and returns a promise of the JSON):
68 |
69 | ~~~~~~~~~~~~~~~~~~~html
70 |
71 | ~~~~~~~~~~~~~~~~~~~
72 |
73 | The CountryChooser component below displays the list of available countries, which are also passed to it as a promise:
74 |
75 | ~~~~~~~~~~~~~~~~~~~javascript
76 | class CountryChooser extends React.Component {
77 | constructor(props) {
78 | super(props);
79 | this.state = {loading: true, error: null, countries: null};
80 | }
81 |
82 | render() {
83 | if (this.state.loading) {
84 | return Loading...;
85 | }
86 | else if (this.state.error !== null) {
87 | return Error: {this.state.error.message};
88 | }
89 | else {
90 | return (
91 |
102 | );
103 | }
104 | }
105 |
106 | componentDidMount() {
107 | this.props.promise.then(
108 | value => this.setState({loading: false, countries: value}),
109 | error => this.setState({loading: false, error: error}));
110 | }
111 | }
112 | ~~~~~~~~~~~~~~~~~~~
113 |
114 | It can be used like this (assuming the same fetchJson function and a changeUsersPreferredCountry function that sends the change of country to the server):
115 |
116 | ~~~~~~~~~~~~~~~~~~~html
117 |
119 | ~~~~~~~~~~~~~~~~~~~
120 |
121 | There’s a lot of duplication between the two components.
122 |
123 | They duplicate the state machine required to receive and render data obtained asynchronously from a promise. These are not the only React components in the application that need to display data loaded asynchronously from the server, so addressing that duplication will shrink the code significantly.
124 |
125 | The CountryChooser component cannot use the Country component to display the countries in the list because the event handling is intermingled with the presentation of the data. It therefore duplicates the code to render a country as HTML. We don't want these HTML fragments diverging, because that will then create further duplication in our CSS stylesheets.
126 |
127 | What can we do?
128 |
129 | We could extract the promise event handling into a base class. But JavaScript only supports single inheritance, so we if our components inherit event handling for promises, they cannot inherit base classes that provide event handling for other things, such as user interaction ^[for example, in recent project we needed to compose live updates, drag-source and drop-target behaviour into stateless rendering components]. And although it disentangles the promise event handling from the rendering, it doesn't disentangle the rendering from the promise event handling, so we still couldn't use the Country component within the CountryChooser.
130 |
131 | It sounds like a job for mixins, but React's mixins don’t work with ES6 classes and are going to be dropped from the API.
132 |
133 | Higher-order components to the rescue!
134 |
135 | What is a Higher-Order Component?
136 | ---------------------------------
137 |
138 | A higher-order component is merely a function from component class to component class. The function takes a component class as a parameter and returns a new component class that wraps useful functionality around the class passed in ^[actually, a higher-order component could take more than one components as parameters, but we only need one in this example]. If you’re familiar with the "Gang of Four" design patterns and are thinking "Decorator pattern", you’re pretty much bang on.
139 |
140 | As a shorthand, in the rest of this article I'm going to call class passed to the function the "decorated class", the class returned by the function the "decorator class", and the function itself as the “higher-order component”. I’ll use “decorated component” and “decorator component” to mean instances of the decorated and decorator classes.
141 |
142 | A decorator component usually handles events on behalf of the decorated component. It maintains some state and communicates with the decorated component by passing state values and callbacks to the decorated component via its props.
143 |
144 | Let's assume we have a higher-order component called Promised that translates a promise of a value into props for a decorated component. The decorator component performs all the state management required to use the promise. This means that decorated components can be stateless, only concerned with presentation.
145 |
146 | The Country component now only needs to display to country information:
147 |
148 | ~~~~~~~~~~~~~~javascript
149 | var Country = ({name, iso}) =>
150 |
151 |
152 | {name}
153 | ;
154 | ~~~~~~~~~~~~~~
155 |
156 | To define a component that receives the country information asynchronously as a promise, we decorate it with the Promised higher-order component:
157 |
158 | ~~~~~~~~~~~~~~javascript
159 | var AsyncCountry = Promised(Country);
160 | ~~~~~~~~~~~~~~
161 |
162 | The CountryChooser can also be written as a stateless component, and can now use the Country component to display each country:
163 |
164 | ~~~~~~~~~~~~~~javascript
165 | var CountryChooser = ({countries, onSelect}) =>
166 |
167 | {
168 | countries.map(c =>
169 |
onSelect(c.iso)}>
170 |
171 |
)
172 | }
173 |
;
174 | ~~~~~~~~~~~~~~
175 |
176 | And can also be decorated with Promised to receive the list of countries as a promise:
177 |
178 | ~~~~~~~~~~~~~~javascript
179 | var AsyncCountryChooser = Promised(CountryChooser);
180 | ~~~~~~~~~~~~~~
181 |
182 | By moving state management into a generic higher-order component, we have made our application-specific components both simpler and more useful, in that they can be used in more contexts.
183 |
184 | Implementing the Higher-Order Component
185 | ---------------------------------------
186 |
187 | The Promised function takes a component class to be decorated as a parameter and returns a new decorator class. Like functions, classes in ES6 are first-class values.
188 |
189 | Client code passes a promise of props for the decorated component to the decorator as a prop named "promise". The decorator passes all other props through to the decorated component unchanged. This lets you configure a Promised(X) component with the same props you would use to configure an undecorated X component. For example, you could initialise the decorator with event callbacks that get passed to the decorated component when it is rendered.
190 |
191 | ~~~~~~~~~~~~~~~javascript
192 | var React = require('react');
193 | var R = require('ramda');
194 |
195 | var Promised = Decorated => class Promised extends React.Component { // (1)
196 | constructor(props) {
197 | super(props);
198 | this.state = {loading: true, error: null, value: null};
199 | }
200 |
201 | render() {
202 | if (this.state.loading) {
203 | return Loading...;
204 | }
205 | else if (this.state.error !== null) {
206 | return Error: {this.state.error.message};
207 | }
208 | else {
209 | var propsWithoutThePromise = R.dissoc('promise', this.props); // (2)
210 | return ;
212 | }
213 | }
214 |
215 | componentDidMount() {
216 | this.props.promise.then(
217 | value => this.setState({loading: false, value: value}),
218 | error => this.setState({loading: false, error: error}));
219 | }
220 | };
221 | ~~~~~~~~~~~~~~~
222 |
223 | A few noteworthy points...
224 |
225 | The parameter name for the decorated component (Decorated in this function) must start with a with a capital letter so that the JSX compiler recognises it as a React component, rather than an HTML DOM element.
226 |
227 | When the decorator renders the decorated component, it creates the props for decorated component by merging its own properties, except for the promise, with the properties of the promise value. The code above uses a [utility function from the Ramda library](http://ramdajs.com/docs/#dissoc) to remove the promise from the decorator component's props, and uses ES6 "spread" syntax to remove merge the props with the properties of the promise value.
228 |
229 | Massaging Props to Avoid Name Clash
230 | -----------------------------------
231 |
232 | The eagle eyed reader will have noticed that the AsyncCountryChooser has a slightly different API from the original CountryChooser component above. The original accepted a promise of an *array* of country objects. But the Promised decorator uses the fields of the promised value as the props of the decorated component, so the promised value must be an object, not an array.
233 |
234 | We can address that by wrapping the array in an object in the promise chain, like this:
235 |
236 | ~~~~~~~~~~~~~~~
237 | {countries: list})}
239 | onSelect={changeCountry}/>,
240 | ~~~~~~~~~~~~~~~
241 |
242 | The name of the "promise" prop is also a problem. If we want to decorate a component that already has a prop named "promise" that we need to pass through the Promised decorator, the current implementation would not let us do so.
243 |
244 | To make the Promised function truly context independent, we need to give the caller control over the prop name used to pass the promise to the decorator, by letting them pass it as a parameter:
245 |
246 | ~~~~~~~~~~~~~~~javascript
247 | var Promised = (promiseProp, Decorated) => class extends React.Component {
248 | constructor(props) {
249 | super(props);
250 | this.state = {loading: true, error: null, value: null};
251 | }
252 |
253 | render() {
254 | if (this.state.loading) {
255 | return Loading...;
256 | }
257 | else if (this.state.error !== null) {
258 | return Error: {this.state.error.message};
259 | }
260 | else {
261 | var propsWithoutThePromise = R.dissoc(promiseProp, this.props);
262 | return ;
263 | }
264 | }
265 |
266 | componentDidMount() {
267 | this.props[promiseProp].then(
268 | value => this.setState({loading: false, value: value}),
269 | error => this.setState({loading: false, error: error}));
270 | }
271 | };
272 | ~~~~~~~~~~~~~~~
273 |
274 | We now have to name the promises when we apply the higher-order component and instantiate the decorated components. But this lets us introduce better names into the code, which is a good thing.
275 |
276 | ~~~~~~~~~~~~~~~javascript
277 | var AsyncCountry = Promised("country", Country);
278 | var AsyncCountryChooser = Promised("countries", CountryChooser);
279 |
280 | ...
281 |
282 |
283 | {countries: list})}
284 | onSelect={changeCountry}/>
285 | ~~~~~~~~~~~~~~~
286 |
287 | If it is to be compatible with arbitrary components, a higher-order component must provide a way to control the interface between the decorator and decorated components to avoid name clash and map the data provided by the decorator to the props expected by the decorated component.
288 |
289 |
290 | Higher-Order Components as Decorators
291 | -------------------------------------
292 |
293 | A future version of EcmaScript may add syntax for [function and class decorators][]. We can make our higher order component a decorator by making it return a function of one parameter that is applied to the decorated component class:
294 |
295 | ~~~~~~~~~~~~~~~javascript
296 | var Promised = promiseProp => Decorated => class extends React.Component {
297 | ...
298 | };
299 | ~~~~~~~~~~~~~~~
300 |
301 | We can then decorate classes to compose in asynchronous loading at class definition time.
302 |
303 | ~~~~~~~~~~~~~~~javascript
304 | @Promised("stockLevel")
305 | class StockLevelIndicator extends React.Component {
306 | ...
307 | }
308 | ~~~~~~~~~~~~~~~
309 |
310 | But when used as a function, our higher-order component is rather clumsy to invoke:
311 |
312 | ~~~~~~~~~~~~~~~javascript
313 | var AsyncCountry = Promised("country")(Country);
314 | ~~~~~~~~~~~~~~~
315 |
316 | If we use Ramda's [curry function], we can support both uses, as a decorator and as a function:
317 |
318 | ~~~~~~~~~~~~~~~javascript
319 | var Promised = R.curry((promiseProp, Decorated) => class extends React.Component {
320 | ...
321 | });
322 |
323 | @Promised("stockLevel")
324 | class StockLevelIndicator extends React.Component {
325 | ...
326 | }
327 |
328 | var AsyncCountry = Promised("country", Country);
329 | ~~~~~~~~~~~~~~~
330 |
331 |
332 | [promise]: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Promise
333 | [es6 class inheritance]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes#Sub_classing_with_extends
334 | [react mixins]: https://facebook.github.io/react/docs/reusable-components.html#mixins
335 | [function and class decorators]: https://github.com/wycats/javascript-decorators
336 | [curry function]: http://ramdajs.com/docs/#curry
337 | [rx event stream]: https://github.com/Reactive-Extensions/RxJS
338 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "horcs-example",
3 | "private": true,
4 | "version": "1.0.0",
5 | "dependencies": {
6 | "flag-icon-css": "^0.8.2",
7 | "ramda": "^0.18.0",
8 | "react": "^0.14.0",
9 | "react-dom": "^0.14.0"
10 | },
11 | "devDependencies": {
12 | "babel": "^5.8.23",
13 | "babelify": "^6.3.0",
14 | "browserify": "^11.2.0",
15 | "http-server": "^0.8.5"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/src/country-chooser.js:
--------------------------------------------------------------------------------
1 |
2 | var React = require('react');
3 | var Country = require('./country');
4 |
5 | var CountryChooser = ({countries, onSelect}) =>
6 |