├── applications ├── counter │ ├── script.js │ └── index.html ├── playground │ ├── script.js │ ├── index.html │ ├── style.scss │ └── playground.js ├── loading-states │ ├── style.scss │ ├── script.js │ ├── index.html │ └── utilities.js ├── basic-counter │ ├── script.js │ ├── utilities.js │ └── index.html ├── pixel-editor │ ├── style.scss │ ├── index.html │ ├── utilities.js │ └── script.js ├── dog-facts │ ├── style.scss │ ├── index.html │ ├── script.js │ └── utilities.js ├── merging-timelines │ ├── script.js │ ├── index.html │ └── utilities.js ├── basic-counter-with-switch-map │ ├── utilities.js │ ├── index.html │ └── script.js ├── mapping │ ├── script.js │ ├── index.html │ ├── utilities.js │ └── star-wars-characters.json ├── pokemon-paginated │ ├── index.html │ └── script.js ├── pokemon-autocomplete │ ├── index.html │ └── script.js ├── from-event │ ├── index.html │ └── script.js ├── pokemon │ ├── index.html │ ├── script.js │ ├── style.scss │ └── utilities.js ├── pokemon-data-enhancement │ ├── index.html │ └── script.js ├── dog-facts-complete │ ├── index.html │ └── script.js ├── deep-thoughts │ ├── script.js │ ├── utilities.js │ └── index.html ├── manipulating-time │ ├── script.js │ ├── index.html │ └── utilities.js ├── mapping-complete │ ├── index.html │ ├── script.js │ └── utilities.js └── deep-thoughts-complete │ ├── script.js │ ├── utilities.js │ └── index.html ├── .postcssrc ├── shared ├── colors.scss ├── fonts │ ├── Press-Start-2P.ttf │ ├── CourierPrime-Bold.ttf │ ├── CourierPrime-Italic.ttf │ ├── AveriaSerifLibre-Bold.ttf │ ├── AveriaSerifLibre-Light.ttf │ ├── CourierPrime-Regular.ttf │ ├── AveriaSerifLibre-Italic.ttf │ ├── AveriaSerifLibre-Regular.ttf │ ├── CourierPrime-BoldItalic.ttf │ ├── AveriaSerifLibre-BoldItalic.ttf │ └── AveriaSerifLibre-LightItalic.ttf ├── home.scss ├── lesson.scss ├── lesson.html ├── application.scss ├── base.html ├── fonts.scss ├── favicon.svg ├── prism.css └── style.scss ├── .editorconfig ├── .babelrc ├── .gitignore ├── .prettierrc ├── utilities ├── fibonacci.js ├── fibonacci.test.js └── dom-manpulation.js ├── .posthtmlrc ├── .eleventy.cjs ├── content ├── _includes │ └── layouts │ │ └── lesson.njk ├── from-event-solution.md ├── switch-map-exercise.md ├── basic-async.md ├── manipulating-time-exercise.md ├── switch-map-solution.md ├── manipulating-time-solution.md ├── paginated-fetching.md ├── combining-operators.md ├── basic-counter-solution.md ├── pixel-editor.md ├── from-event-exercise.md ├── combining-operators-solution.md ├── autocomplete.md ├── creating-observables-exercise.md ├── basic-operators-solution.md ├── first-and-last-name.md ├── basic-async-solution.md ├── subjects.md ├── loading-state.md ├── basic-counter.md ├── creating-observables-solution.md ├── progressive-data-enhancement.md ├── switch-map.md ├── manipulating-time-follow-along.md ├── merging-timelines-follow-along.md ├── basic-operators.md ├── mapping-follow-along.md ├── fetching-dog-facts.md └── _counter-follow-along.md ├── exercises ├── basic-observables.test.js ├── basic-operators.test.js └── creating-observables.test.js ├── .eslintc ├── README.md ├── package.json ├── api ├── views │ └── index.html └── server.js └── index.html /applications/counter/script.js: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.postcssrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "postcss-fontpath": {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /shared/colors.scss: -------------------------------------------------------------------------------- 1 | $teal: #2aa298; 2 | $coral: #f76e6e; 3 | $blue: #6fbef6; 4 | $green: #08916a; 5 | $fuschia: #d6438a; 6 | -------------------------------------------------------------------------------- /shared/fonts/Press-Start-2P.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/rxjs-fundamentals/HEAD/shared/fonts/Press-Start-2P.ttf -------------------------------------------------------------------------------- /shared/fonts/CourierPrime-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/rxjs-fundamentals/HEAD/shared/fonts/CourierPrime-Bold.ttf -------------------------------------------------------------------------------- /shared/fonts/CourierPrime-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/rxjs-fundamentals/HEAD/shared/fonts/CourierPrime-Italic.ttf -------------------------------------------------------------------------------- /applications/playground/script.js: -------------------------------------------------------------------------------- 1 | import { of } from 'rxjs'; 2 | import './playground'; 3 | 4 | export const example$ = of(1, 2, 3, 4); 5 | -------------------------------------------------------------------------------- /shared/fonts/AveriaSerifLibre-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/rxjs-fundamentals/HEAD/shared/fonts/AveriaSerifLibre-Bold.ttf -------------------------------------------------------------------------------- /shared/fonts/AveriaSerifLibre-Light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/rxjs-fundamentals/HEAD/shared/fonts/AveriaSerifLibre-Light.ttf -------------------------------------------------------------------------------- /shared/fonts/CourierPrime-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/rxjs-fundamentals/HEAD/shared/fonts/CourierPrime-Regular.ttf -------------------------------------------------------------------------------- /shared/fonts/AveriaSerifLibre-Italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/rxjs-fundamentals/HEAD/shared/fonts/AveriaSerifLibre-Italic.ttf -------------------------------------------------------------------------------- /shared/fonts/AveriaSerifLibre-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/rxjs-fundamentals/HEAD/shared/fonts/AveriaSerifLibre-Regular.ttf -------------------------------------------------------------------------------- /shared/fonts/CourierPrime-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/rxjs-fundamentals/HEAD/shared/fonts/CourierPrime-BoldItalic.ttf -------------------------------------------------------------------------------- /shared/fonts/AveriaSerifLibre-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/rxjs-fundamentals/HEAD/shared/fonts/AveriaSerifLibre-BoldItalic.ttf -------------------------------------------------------------------------------- /shared/fonts/AveriaSerifLibre-LightItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stevekinney/rxjs-fundamentals/HEAD/shared/fonts/AveriaSerifLibre-LightItalic.ttf -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | charset = utf-8 7 | indent_style = space 8 | indent_size = 2 9 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": ["@babel/preset-env"], 5 | "plugins": ["@babel/plugin-transform-runtime"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | .DS_Store 3 | .DS_Store 4 | .parcel-cache 5 | *.local 6 | dist 7 | dist-ssr 8 | node_modules 9 | public/ 10 | yarn-error.log 11 | lessons/ 12 | -------------------------------------------------------------------------------- /applications/loading-states/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../shared/colors.scss'; 2 | 3 | .currently-loading { 4 | background: lighten($blue, 10%); 5 | border: 1px solid darken($blue, 10%); 6 | margin: 1em 0; 7 | padding: 1em; 8 | } 9 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": true, 7 | "trailingComma": "all", 8 | "bracketSpacing": true, 9 | "jsxBracketSameLine": false 10 | } 11 | -------------------------------------------------------------------------------- /utilities/fibonacci.js: -------------------------------------------------------------------------------- 1 | export function* fibonacci() { 2 | let values = [0, 1]; 3 | 4 | while (true) { 5 | let [current, next] = values; 6 | 7 | yield current; 8 | 9 | values = [next, current + next]; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /applications/basic-counter/script.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, interval, merge, NEVER } from 'rxjs'; 2 | import { setCount, startButton, pauseButton } from './utilities'; 3 | 4 | const start$ = fromEvent(startButton, 'click'); 5 | const pause$ = fromEvent(pauseButton, 'click'); 6 | -------------------------------------------------------------------------------- /applications/pixel-editor/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../shared/colors.scss'; 2 | 3 | #panel { 4 | position: absolute; 5 | left: 1100px; 6 | top: 180px; 7 | background: $blue; 8 | padding: 1em; 9 | border: 1px solid; 10 | &.moving { 11 | background: $fuschia; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.posthtmlrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": { 3 | "posthtml-doctype": { 4 | "doctype": "HTML 5" 5 | }, 6 | "posthtml-extend": { 7 | "root": "./shared/" 8 | }, 9 | "posthtml-prism": { 10 | "highlight": { 11 | "inline": true 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /applications/dog-facts/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../shared/colors.scss'; 2 | 3 | .controls { 4 | flex-direction: row; 5 | justify-content: center; 6 | width: 100%; 7 | margin-bottom: 1em; 8 | } 9 | 10 | .dog-fact { 11 | border: 1px solid $green; 12 | margin: 1em; 13 | padding: 1em; 14 | } 15 | -------------------------------------------------------------------------------- /applications/merging-timelines/script.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, merge, interval, concat, race, forkJoin } from 'rxjs'; 2 | import { mapTo, startWith, take, map } from 'rxjs/operators'; 3 | import { 4 | labelWith, 5 | startButton, 6 | pauseButton, 7 | setStatus, 8 | bootstrap, 9 | } from './utilities'; 10 | -------------------------------------------------------------------------------- /applications/basic-counter-with-switch-map/utilities.js: -------------------------------------------------------------------------------- 1 | export const count = document.getElementById('count'); 2 | export const startButton = document.getElementById('start'); 3 | export const pauseButton = document.getElementById('pause'); 4 | 5 | export const setCount = (value) => { 6 | count.innerText = value; 7 | }; 8 | -------------------------------------------------------------------------------- /.eleventy.cjs: -------------------------------------------------------------------------------- 1 | module.exports = function (eleventyConfig) { 2 | eleventyConfig.addFilter('code', (content) => { 3 | return content.replace(/=>/g, '=>'); 4 | }); 5 | 6 | return { 7 | dir: { 8 | input: 'content', 9 | output: 'lessons', 10 | quiet: true, 11 | }, 12 | }; 13 | }; 14 | -------------------------------------------------------------------------------- /shared/home.scss: -------------------------------------------------------------------------------- 1 | @import './colors.scss'; 2 | 3 | ul, 4 | ol { 5 | padding-left: 0; 6 | } 7 | 8 | li { 9 | font-size: 1em; 10 | font-size: 0.9em; 11 | text-indent: 0.5em; 12 | } 13 | 14 | ol > li { 15 | list-style: lower-roman; 16 | } 17 | 18 | .lessons, 19 | .applications { 20 | width: 100%; 21 | } 22 | -------------------------------------------------------------------------------- /applications/basic-counter/utilities.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, interval } from 'rxjs'; 2 | 3 | export const count = document.querySelector('.count'); 4 | export const startButton = document.getElementById('start'); 5 | export const pauseButton = document.getElementById('pause'); 6 | 7 | export const setCount = (value) => { 8 | count.innerText = value; 9 | }; 10 | -------------------------------------------------------------------------------- /applications/loading-states/script.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, concat, of, race, timer } from 'rxjs'; 2 | import { tap, exhaustMap, delay, shareReplay, first } from 'rxjs/operators'; 3 | 4 | import { 5 | responseTimeField, 6 | showLoadingAfterField, 7 | showLoadingForAtLeastField, 8 | loadingStatus, 9 | showLoading, 10 | form, 11 | fetchData, 12 | } from './utilities'; 13 | -------------------------------------------------------------------------------- /content/_includes/layouts/lesson.njk: -------------------------------------------------------------------------------- 1 | --- 2 | title: RxJS Fundamentals 3 | --- 4 | 5 | 6 | {{ title }} - RxJS Fundamentals 7 | {{title}} 8 | 9 |
10 | {{ content | code | safe }} 11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /applications/pixel-editor/index.html: -------------------------------------------------------------------------------- 1 | 2 | Pixel Editor 3 | 4 |

Pixel Editor

5 |
6 | 7 | 8 |
9 | 10 |
11 |
12 | -------------------------------------------------------------------------------- /applications/mapping/script.js: -------------------------------------------------------------------------------- 1 | import { of, from, interval, fromEvent, merge, NEVER } from 'rxjs'; 2 | import { pluck, concatMap, take, map } from 'rxjs/operators'; 3 | 4 | import { 5 | getCharacter, 6 | render, 7 | startButton, 8 | pauseButton, 9 | setStatus, 10 | } from './utilities'; 11 | 12 | const character$ = from(getCharacter(1)).pipe(pluck('name')); 13 | 14 | character$.subscribe(render); 15 | -------------------------------------------------------------------------------- /applications/pokemon-paginated/index.html: -------------------------------------------------------------------------------- 1 | 2 | Pokémon API 3 | 4 |

Pokémon API

5 |
6 | 7 | 8 |
9 | 10 | 11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /applications/pokemon-autocomplete/index.html: -------------------------------------------------------------------------------- 1 | 2 | Pokémon API 3 | 4 |

Pokémon API

5 |
6 | 7 | 8 |
9 | 10 | 11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /applications/from-event/index.html: -------------------------------------------------------------------------------- 1 | 2 | Notification Center 3 | 4 |

Notification Center

5 |
6 |
7 | 8 |
9 |
10 |
11 |
12 |
13 | -------------------------------------------------------------------------------- /applications/pokemon/index.html: -------------------------------------------------------------------------------- 1 | 2 | Pokémon API 3 | 4 |

Pokémon API

5 |
6 | 7 | 8 |
9 | 10 | 11 |
12 |
13 |
14 |
15 | -------------------------------------------------------------------------------- /applications/pokemon-data-enhancement/index.html: -------------------------------------------------------------------------------- 1 | 2 | Pokémon API 3 | 4 |

Pokémon API

5 |
6 | 7 | 8 |
9 | 10 | 11 |
12 |
13 |
14 |
15 | -------------------------------------------------------------------------------- /applications/playground/index.html: -------------------------------------------------------------------------------- 1 | 2 | Playground 3 | 4 |

Playground

5 |
6 |
7 | 8 | 9 | 10 |
11 |
12 |
13 |
14 |
15 | -------------------------------------------------------------------------------- /applications/dog-facts/index.html: -------------------------------------------------------------------------------- 1 | 2 | Dog Facts 3 | 4 |

Dog Facts

5 |
6 |
7 | 8 | 9 |
10 |
11 |
12 | 13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /applications/basic-counter/index.html: -------------------------------------------------------------------------------- 1 | 2 | Counter 3 | 4 |

Counter

5 |
6 |
0
7 |
8 |
9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /applications/dog-facts-complete/index.html: -------------------------------------------------------------------------------- 1 | 2 | Dog Facts 3 | 4 |

Dog Facts

5 |
6 |
7 | 8 | 9 |
10 |
11 |
12 | 13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /applications/deep-thoughts/script.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, interval } from 'rxjs'; 2 | import { debounceTime, map, tap } from 'rxjs/operators'; 3 | 4 | import { renderMarkdown, deepThoughtInput, setStatus } from './utilities'; 5 | 6 | const textAreaChanges$ = fromEvent(deepThoughtInput, 'input').pipe( 7 | map((event) => event.target.value), 8 | tap(() => setStatus('Rendering…')), 9 | debounceTime(2000), 10 | tap(renderMarkdown), 11 | tap(() => setStatus('')), 12 | ); 13 | 14 | textAreaChanges$.subscribe(); 15 | -------------------------------------------------------------------------------- /applications/basic-counter-with-switch-map/index.html: -------------------------------------------------------------------------------- 1 | 2 | Counter 3 | 4 |

Counter

5 |
6 |
0
7 |
8 |
9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /applications/manipulating-time/script.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, interval } from 'rxjs'; 2 | import { 3 | throttleTime, 4 | debounceTime, 5 | delay, 6 | debounce, 7 | throttle, 8 | scan, 9 | map, 10 | tap, 11 | } from 'rxjs/operators'; 12 | 13 | import { 14 | button, 15 | panicButton, 16 | addMessageToDOM, 17 | deepThoughtInput, 18 | setTextArea, 19 | setStatus, 20 | } from './utilities'; 21 | 22 | const buttonClicks$ = fromEvent(button, 'click'); 23 | 24 | buttonClicks$.subscribe(addMessageToDOM); 25 | -------------------------------------------------------------------------------- /exercises/basic-observables.test.js: -------------------------------------------------------------------------------- 1 | import { from, of } from 'rxjs'; 2 | 3 | describe('Basic Observables', () => { 4 | describe(of, () => { 5 | it.skip('should create an observable from its arguments', () => { 6 | const result = []; 7 | 8 | expect(result).toEqual([1, 2, 3, 4]); 9 | }); 10 | }); 11 | 12 | describe(from, () => { 13 | it.skip('should create an observable', () => { 14 | const result = []; 15 | 16 | expect(result).toEqual([1, 2, 3, 4]); 17 | }); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /applications/dog-facts/script.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, of, timer, merge, NEVER } from 'rxjs'; 2 | import { fromFetch } from 'rxjs/fetch'; 3 | import { 4 | catchError, 5 | exhaustMap, 6 | mapTo, 7 | mergeMap, 8 | retry, 9 | startWith, 10 | switchMap, 11 | tap, 12 | pluck, 13 | } from 'rxjs/operators'; 14 | 15 | import { 16 | fetchButton, 17 | stopButton, 18 | clearError, 19 | clearFacts, 20 | addFacts, 21 | setError, 22 | } from './utilities'; 23 | 24 | const endpoint = 'http://localhost:3333/api/facts'; 25 | -------------------------------------------------------------------------------- /applications/manipulating-time/index.html: -------------------------------------------------------------------------------- 1 | 2 | Notification Center 3 | 4 |

Notification Center

5 |
6 |
7 |
8 | 9 | 10 |
11 |
12 |
13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /applications/pokemon/script.js: -------------------------------------------------------------------------------- 1 | import { 2 | debounceTime, 3 | distinctUntilChanged, 4 | fromEvent, 5 | map, 6 | mergeMap, 7 | switchMap, 8 | tap, 9 | of, 10 | merge, 11 | from, 12 | filter, 13 | catchError, 14 | concat, 15 | take, 16 | EMPTY, 17 | } from 'rxjs'; 18 | 19 | import { fromFetch } from 'rxjs/fetch'; 20 | 21 | import { 22 | addResults, 23 | addResult, 24 | clearResults, 25 | endpointFor, 26 | search, 27 | form, 28 | } from '../pokemon/utilities'; 29 | 30 | const endpoint = 'http://localhost:3333/api/pokemon?delay=100'; 31 | -------------------------------------------------------------------------------- /applications/pokemon/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../shared/colors.scss'; 2 | 3 | #fetch-form { 4 | width: 100%; 5 | display: flex; 6 | gap: 0.5em; 7 | margin-bottom: 1em; 8 | input { 9 | width: 100%; 10 | } 11 | button { 12 | width: 200px; 13 | } 14 | } 15 | 16 | article { 17 | color: $teal; 18 | } 19 | 20 | #pokemon { 21 | padding: 1em; 22 | // border: 1px solid $coral; 23 | } 24 | 25 | .data { 26 | display: flex; 27 | border: 1px solid $blue; 28 | padding: 1em; 29 | table { 30 | width: 100%; 31 | text-align: center; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /content/from-event-solution.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: fromEvent (Solution) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | This one is pretty simple and it's not _that_ much different than just using an event listener. But, we're building to something bigger. I promise. 7 | 8 | ```js 9 | const buttonClicks$ = fromEvent(button, 'click'); 10 | 11 | buttonClicks$.subscribe(() => { 12 | addMessageToDOM(); 13 | }); 14 | ``` 15 | 16 | You can even shorted it as follows: 17 | 18 | ```js 19 | const buttonClicks$ = fromEvent(button, 'click'); 20 | 21 | buttonClicks$.subscribe(addMessageToDOM); 22 | ``` 23 | -------------------------------------------------------------------------------- /applications/mapping/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Mapping 4 | 5 | 6 |

Mapping

7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /utilities/fibonacci.test.js: -------------------------------------------------------------------------------- 1 | import { from } from 'rxjs'; 2 | import { take } from 'rxjs/operators'; 3 | import { fibonacci } from './fibonacci'; 4 | 5 | describe(fibonacci, () => { 6 | it('should generate a series of values following the fibonacci sequence', (done) => { 7 | const result = []; 8 | const fibonacci$ = from(fibonacci()).pipe(take(10)); 9 | 10 | fibonacci$.subscribe({ 11 | next: (value) => result.push(value), 12 | complete: () => { 13 | expect(result).toEqual([0, 1, 1, 2, 3, 5, 8, 13, 21, 34]); 14 | done(); 15 | }, 16 | }); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /applications/basic-counter-with-switch-map/script.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, interval, merge, NEVER } from 'rxjs'; 2 | import { switchMap, mapTo, scan } from 'rxjs/operators'; 3 | import { setCount, startButton, pauseButton } from './utilities'; 4 | 5 | const start$ = fromEvent(startButton, 'click').pipe(mapTo(true)); 6 | const pause$ = fromEvent(pauseButton, 'click').pipe(mapTo(false)); 7 | 8 | const counter$ = merge(start$, pause$).pipe( 9 | switchMap((isRunning) => { 10 | return isRunning ? interval(1000) : NEVER; 11 | }), 12 | scan((count) => count + 1, 0), 13 | ); 14 | 15 | counter$.subscribe(setCount); 16 | -------------------------------------------------------------------------------- /applications/mapping-complete/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Mapping 4 | 5 | 6 |

Mapping

7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | -------------------------------------------------------------------------------- /applications/deep-thoughts-complete/script.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, interval } from 'rxjs'; 2 | import { 3 | throttleTime, 4 | debounceTime, 5 | delay, 6 | debounce, 7 | throttle, 8 | scan, 9 | map, 10 | tap, 11 | } from 'rxjs/operators'; 12 | 13 | import { renderMarkdown, deepThoughtInput, setStatus } from './utilities'; 14 | 15 | const textAreaChanges$ = fromEvent(deepThoughtInput, 'input').pipe( 16 | map((event) => event.target.value), 17 | tap(() => setStatus('Rendering…')), 18 | debounceTime(2000), 19 | tap(renderMarkdown), 20 | tap(() => setStatus('')), 21 | ); 22 | 23 | textAreaChanges$.subscribe(); 24 | -------------------------------------------------------------------------------- /applications/deep-thoughts/utilities.js: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it'; 2 | 3 | const markdown = new MarkdownIt(); 4 | 5 | export const deepThoughtInput = document.getElementById('deep-thought'); 6 | export const deepThoughtRendered = document.getElementById( 7 | 'deep-thought-rendered', 8 | ); 9 | export const deepThroughtStatus = document.getElementById( 10 | 'deep-thought-status', 11 | ); 12 | 13 | export const renderMarkdown = (content) => { 14 | deepThoughtRendered.innerHTML = markdown.render(content); 15 | }; 16 | 17 | export const setStatus = (content) => { 18 | deepThroughtStatus.innerHTML = content; 19 | }; 20 | -------------------------------------------------------------------------------- /.eslintc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "eslint:recommended", 4 | "plugin:import/errors", 5 | "prettier", 6 | "prettier/react" 7 | ], 8 | "rules": { 9 | "no-console": 1 10 | }, 11 | "plugins": [ 12 | "import", 13 | "jsx-a11y" 14 | ], 15 | "parser": "babel-eslint", 16 | "parserOptions": { 17 | "ecmaVersion": 2018, 18 | "sourceType": "module", 19 | "ecmaFeatures": { 20 | "jsx": true 21 | } 22 | }, 23 | "env": { 24 | "es6": true, 25 | "browser": true, 26 | "node": true 27 | }, 28 | "settings": { 29 | "react": { 30 | "version": "16.5.2" 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /applications/deep-thoughts-complete/utilities.js: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it'; 2 | 3 | const markdown = new MarkdownIt(); 4 | 5 | export const deepThoughtInput = document.getElementById('deep-thought'); 6 | export const deepThoughtRendered = document.getElementById( 7 | 'deep-thought-rendered', 8 | ); 9 | export const deepThroughtStatus = document.getElementById( 10 | 'deep-thought-status', 11 | ); 12 | 13 | export const renderMarkdown = (content) => { 14 | deepThoughtRendered.innerHTML = markdown.render(content); 15 | }; 16 | 17 | export const setStatus = (content) => { 18 | deepThroughtStatus.innerHTML = content; 19 | }; 20 | -------------------------------------------------------------------------------- /content/switch-map-exercise.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Switch Map (Exercise) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Okay, this seems like a great opportunity to improve our counter again. 7 | 8 | When last we visited it, we had a single use counter. But, now we know how to start it up, turn it off, and then turn it back on again. This seems like it might be useful for us. 9 | 10 | ## Your Mission 11 | 12 | Use `switchMap` to switch between an `interval` timer and `NEVER` based on the the "Start" and "Stop" buttons. 13 | 14 | If you get stuck, you can [look here](https://github.com/stevekinney/rxjs-fundamentals/blob/master/applications/playground/playground.js) for inspiration. 15 | -------------------------------------------------------------------------------- /applications/deep-thoughts/index.html: -------------------------------------------------------------------------------- 1 | 2 | Deep Thoughts 3 | 4 |

Deep Thoughts

5 |
6 |
7 |
8 |
9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /applications/deep-thoughts-complete/index.html: -------------------------------------------------------------------------------- 1 | 2 | Notification Center 3 | 4 |

Notification Center

5 |
6 |
7 |
8 |
9 | 10 | 11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | -------------------------------------------------------------------------------- /content/basic-async.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic Async 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Earlier, we saw that `fromFetch` will create an observable from the Fetch API. Knowing what we now know about promises. Can you head over to the [playground](/applications/playground) and implement an observable that hits our Pokémon API? 7 | 8 | Here is what you need to know: 9 | 10 | - The API should be running on `http://localhost:3333/api/pokemon`. 11 | - Remember: You need to call the observable `example` and export it using `export const` for it to work. 12 | 13 | **Nota bene**: If you're struggling to get your local server up and running, the API is also hosted at https://rxjs-api.glitch.me/api/pokemon. 14 | -------------------------------------------------------------------------------- /applications/merging-timelines/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Merging Timelines 4 | 5 | 6 |

Merging Timelines

7 |
8 |
9 | 10 | 11 | 12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | -------------------------------------------------------------------------------- /shared/lesson.scss: -------------------------------------------------------------------------------- 1 | @import './colors.scss'; 2 | 3 | body { 4 | margin-bottom: 8em; 5 | } 6 | 7 | pre { 8 | padding: 1em; 9 | } 10 | 11 | pre, 12 | code { 13 | background-color: #111111; 14 | } 15 | 16 | .lesson, 17 | .lesson-header { 18 | margin: auto; 19 | max-width: 800px; 20 | h1 { 21 | font-size: 40px; 22 | } 23 | pre { 24 | font-size: 20px; 25 | } 26 | } 27 | 28 | .lesson-header { 29 | margin-bottom: 1em; 30 | } 31 | 32 | .navigation { 33 | padding: 0.5em; 34 | position: sticky; 35 | color: white; 36 | background-color: black; 37 | } 38 | 39 | .navigation a { 40 | font-family: 'Press Start 2P', sans-serif; 41 | color: white; 42 | text-decoration: none; 43 | &:hover { 44 | color: lighten($blue, 20%); 45 | } 46 | &:active { 47 | color: lighten($blue, 10%); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /applications/from-event/script.js: -------------------------------------------------------------------------------- 1 | import { fromEvent } from 'rxjs'; 2 | 3 | const button = document.getElementById('create-notification'); 4 | const notificationMessages = document.getElementById('notification-messages'); 5 | 6 | const createNotificationElement = () => { 7 | const element = document.createElement('article'); 8 | element.innerText = 'Something happened.'; 9 | return element; 10 | }; 11 | 12 | const addMessageToDOM = () => { 13 | const notification = createNotificationElement(); 14 | notificationMessages.appendChild(notification); 15 | }; 16 | 17 | /** 18 | * Your mission: 19 | * 20 | * - Use `fromEvent` to create an observable that streams click events. 21 | * - Subscribe to that observable. 22 | * - Use `addMessageToDOM` to add a useless message to the DOM whenever the 23 | * stream emits a value. 24 | */ 25 | -------------------------------------------------------------------------------- /applications/playground/style.scss: -------------------------------------------------------------------------------- 1 | @import '../../shared/colors.scss'; 2 | 3 | pre { 4 | padding: 0; 5 | margin: 0.5em; 6 | text-align: left; 7 | } 8 | 9 | pre.raw { 10 | background-color: white; 11 | color: darken($fuschia, 20%); 12 | border: 1px solid $fuschia; 13 | padding: 0.5em; 14 | } 15 | 16 | pre.raw > code { 17 | width: 100%; 18 | } 19 | 20 | .playground { 21 | display: flex; 22 | gap: 1em; 23 | } 24 | 25 | .controls { 26 | border-width: 0; 27 | padding: 0; 28 | margin: 0; 29 | width: 300px; 30 | display: block; 31 | button { 32 | display: inline-block; 33 | } 34 | } 35 | 36 | #result { 37 | width: 100%; 38 | } 39 | 40 | .playground-event { 41 | background-color: $coral; 42 | color: white; 43 | border: 1px solid darken($coral, 10%); 44 | text-align: center; 45 | margin-bottom: 1em; 46 | } 47 | -------------------------------------------------------------------------------- /content/manipulating-time-exercise.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown Renderer (Exercise) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Most applications that have some kind of autosave functionality don't automatically save your work on every keystroke. Instead, they will save at regular intervals. 7 | 8 | At the same time, there is no point saving if nothing have changed. We don't have a server, but we could use the same ideas to build a simple markdown renderer that only renders Markdown to the page when the user has stopped typing for a bit. 9 | 10 | Can you implement the following in `applications/manipulating-time`: 11 | 12 | - Listen for `input` events on the `textarea`. 13 | - Map the event object to the value of the `textarea`. 14 | - When our author has stopped typing for a bit go ahead and render the resulting Markdown using the `renderMarkdown` helper. 15 | -------------------------------------------------------------------------------- /content/switch-map-solution.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Switch Map (Solution) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | You can see an implementation of this [here](/applications/basic-counter-with-switch-map/index.html). 7 | 8 | ```js 9 | import { fromEvent, interval, merge, NEVER } from 'rxjs'; 10 | import { switchMap, mapTo, scan } from 'rxjs/operators'; 11 | import { setCount, startButton, pauseButton } from './utilities'; 12 | 13 | const start$ = fromEvent(startButton, 'click').pipe(mapTo(true)); 14 | const pause$ = fromEvent(pauseButton, 'click').pipe(mapTo(false)); 15 | 16 | const counter$ = merge(start$, pause$).pipe( 17 | switchMap((isRunning) => { 18 | return isRunning ? interval(1000) : NEVER; 19 | }), 20 | scan((count) => count + 1, 0), 21 | ); 22 | 23 | counter$.subscribe(setCount); 24 | ``` 25 | 26 | There aren't really any major flaws anymore with our counter. 27 | -------------------------------------------------------------------------------- /applications/counter/index.html: -------------------------------------------------------------------------------- 1 | 2 | Counter 3 | 4 |

Counter

5 |
6 |
0
7 |
8 |
9 | 10 | 11 |
12 |
13 | 14 | 15 | 16 |
17 |
18 | 19 | 20 |
21 |
22 |
23 |
24 |
25 | -------------------------------------------------------------------------------- /applications/pokemon-autocomplete/script.js: -------------------------------------------------------------------------------- 1 | import { 2 | debounceTime, 3 | distinctUntilChanged, 4 | fromEvent, 5 | map, 6 | mergeMap, 7 | switchMap, 8 | tap, 9 | of, 10 | merge, 11 | from, 12 | pluck, 13 | } from 'rxjs'; 14 | 15 | import { fromFetch } from 'rxjs/fetch'; 16 | 17 | import { 18 | addResults, 19 | clearResults, 20 | endpoint, 21 | endpointFor, 22 | search, 23 | } from '../pokemon/utilities'; 24 | 25 | const search$ = fromEvent(search, 'input').pipe( 26 | debounceTime(300), 27 | map((event) => event.target.value), 28 | // distinctUntilChanged(), 29 | switchMap((searchTerm) => 30 | fromFetch(endpoint + searchTerm + '?delay=5000&chaos=true').pipe( 31 | mergeMap((response) => response.json()), 32 | ), 33 | ), 34 | tap(clearResults), 35 | map((response) => response.pokemon), 36 | tap(addResults), 37 | ); 38 | 39 | search$.subscribe(console.log); 40 | -------------------------------------------------------------------------------- /applications/dog-facts/utilities.js: -------------------------------------------------------------------------------- 1 | import { createElement } from '../../utilities/dom-manpulation'; 2 | import './style.scss'; 3 | 4 | export const errorStatus = document.getElementById('error'); 5 | export const fetchButton = document.getElementById('get-dog-facts'); 6 | export const stopButton = document.getElementById('stop-dog-facts'); 7 | export const factsSection = document.getElementById('dog-facts'); 8 | 9 | export const clearFacts = () => { 10 | factsSection.innerText = ''; 11 | }; 12 | 13 | export const setError = (error) => { 14 | errorStatus.innerText = error; 15 | }; 16 | 17 | export const clearError = () => { 18 | errorStatus.innerText = ''; 19 | }; 20 | 21 | export const addFact = ({ fact }) => { 22 | factsSection.appendChild(createElement(fact, { classList: ['dog-fact'] })); 23 | }; 24 | export const addFacts = (data) => data.facts.forEach(addFact); 25 | 26 | export const endpoint = 'https://localhost:3333/api/facts'; 27 | -------------------------------------------------------------------------------- /applications/playground/playground.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, merge, NEVER } from 'rxjs'; 2 | import { mapTo, switchMap } from 'rxjs/operators'; 3 | import { addElementToDOM, emptyElement } from '../../utilities/dom-manpulation'; 4 | import { example$ } from './script'; 5 | 6 | import './style.scss'; 7 | 8 | const play = document.getElementById('play'); 9 | const pause = document.getElementById('pause'); 10 | const clear = document.getElementById('clear'); 11 | const result = document.getElementById('result'); 12 | 13 | const play$ = fromEvent(play, 'click').pipe(mapTo(true)); 14 | const pause$ = fromEvent(pause, 'click').pipe(mapTo(false)); 15 | const clear$ = fromEvent(clear, 'click'); 16 | 17 | const playground$ = merge(play$, pause$).pipe( 18 | switchMap((isRunning) => { 19 | return isRunning ? example$ : NEVER; 20 | }), 21 | ); 22 | 23 | playground$.subscribe((value) => { 24 | console.log(value); 25 | addElementToDOM(result, value, { classList: ['playground-event'] }); 26 | }); 27 | 28 | clear$.subscribe(() => emptyElement(result)); 29 | -------------------------------------------------------------------------------- /content/manipulating-time-solution.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Markdown Renderer (Solution) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Our first pass might look something like this: 7 | 8 | ```js 9 | const textAreaChanges$ = fromEvent(deepThoughtInput, 'input').pipe( 10 | map((event) => event.target.value), 11 | debounceTime(333), 12 | ); 13 | 14 | textAreaChanges$.subscribe(setTextArea); 15 | ``` 16 | 17 | We might use `tap` to create side effects that reflect the current state of our observable. 18 | 19 | ```js 20 | const textAreaChanges$ = fromEvent(deepThoughtInput, 'input').pipe( 21 | map((event) => event.target.value), 22 | tap(() => setStatus('Rendering…')), 23 | debounceTime(2000), 24 | tap(renderMarkdown), 25 | tap(() => setStatus('')), 26 | ); 27 | 28 | textAreaChanges$.subscribe(); 29 | ``` 30 | 31 | In this case, `subscribe()` is just setting up our observable, but everything else is happing as part of the stream. I'm not going to tell you that there is a right or wrong way to do this. It depends on what you're trying to do with a healthy dose of personal preference. 32 | -------------------------------------------------------------------------------- /applications/loading-states/index.html: -------------------------------------------------------------------------------- 1 | 2 | Loading States 3 | 4 |

Loading States

5 | 6 |
7 |
8 | 9 | 10 |
11 | 12 |
13 | 14 | 15 |
16 | 17 |
18 | 21 | 27 |
28 | 29 | 30 |
31 |
32 |
33 |
34 | -------------------------------------------------------------------------------- /applications/manipulating-time/utilities.js: -------------------------------------------------------------------------------- 1 | import MarkdownIt from 'markdown-it'; 2 | import { createElement } from '../../utilities/dom-manpulation'; 3 | 4 | const markdown = new MarkdownIt(); 5 | 6 | export const button = document.getElementById('create-notification'); 7 | export const panicButton = document.getElementById('panic-button'); 8 | export const notificationMessages = document.getElementById( 9 | 'notification-messages', 10 | ); 11 | export const deepThoughtInput = document.getElementById('deep-thought'); 12 | export const deepThoughtRendered = document.getElementById( 13 | 'deep-thought-rendered', 14 | ); 15 | export const deepThroughtStatus = document.getElementById( 16 | 'deep-thought-status', 17 | ); 18 | 19 | export const addMessageToDOM = () => { 20 | const notification = createElement('Something happened'); 21 | notificationMessages.appendChild(notification); 22 | }; 23 | 24 | export const renderMarkdown = (content) => { 25 | deepThoughtRendered.innerHTML = markdown.render(content); 26 | }; 27 | 28 | export const setStatus = (content) => { 29 | deepThroughtStatus.innerHTML = content; 30 | }; 31 | -------------------------------------------------------------------------------- /content/paginated-fetching.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Pagination 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Here is a quick bonus recipe that I'm throwing into the mix because I literally needed to use it the other day. But, also I think it demonstrates a time when we might use `concat` and it's also our first appearance of `EMPTY`. 7 | 8 | So, that Pokémon API. There is that next page token. Could we iterate down the pages? 9 | 10 | ```js 11 | const endpoint = 'http://localhost:3333/api/pokemon?delay=100'; 12 | 13 | export const getData = (url = endpoint) => { 14 | return fromFetch(url).pipe( 15 | mergeMap((response) => response.json()), 16 | mergeMap((response) => { 17 | const next$ = response.nextPage 18 | ? getData(endpoint + '&page=' + response.nextPage) 19 | : EMPTY; 20 | return concat(of(response.pokemon), next$); 21 | }), 22 | filter(Boolean), 23 | take(4), 24 | tap(addResults), 25 | catchError(console.error), 26 | ); 27 | }; 28 | 29 | const fetch$ = fromEvent(form, 'submit').pipe( 30 | switchMap(() => getData(endpoint)), 31 | ); 32 | 33 | fetch$.subscribe(console.log); 34 | ``` 35 | -------------------------------------------------------------------------------- /applications/pixel-editor/utilities.js: -------------------------------------------------------------------------------- 1 | import './style.scss'; 2 | 3 | export const canvas = document.getElementById('canvas'); 4 | export const color = document.getElementById('color'); 5 | export const panel = document.getElementById('panel'); 6 | 7 | export const ctx = canvas.getContext('2d'); 8 | 9 | export const roundDown = (n) => Math.floor(n / 10) * 10; 10 | export const roundDownPoints = (points) => points.map(roundDown); 11 | export const pointsAreEqual = (previous, current) => { 12 | return previous[0] === current[0] && previous[1] === current[1]; 13 | }; 14 | 15 | export const getCoordinates = (event) => [event.offsetX, event.offsetY]; 16 | 17 | export const drawLine = ([point, color]) => { 18 | const [x, y] = point; 19 | ctx.fillStyle = color; 20 | ctx.fillRect(x, y, 10, 10); 21 | }; 22 | 23 | export const drawCircle = ([points, color]) => { 24 | ctx.fillStyle = color; 25 | const [startX, startY] = points.start; 26 | const [currentX, currentY] = points.current; 27 | 28 | ctx.beginPath(); 29 | ctx.arc( 30 | startX, 31 | startY, 32 | Math.abs(Math.ceil(startX - currentX, startY - currentY)), 33 | 0, 34 | 2 * Math.PI, 35 | false, 36 | ); 37 | ctx.fill(); 38 | }; 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [RxJS Fundamentals](https://frontendmasters.com/courses/rx-js/) 2 | 3 | This respository contains the exercises and sample applications used in [Steve's](https://twitter.com/stevekinney) RxJS Fundamentals course for [Frontend Masters](https://frontendmasters.com). 4 | 5 | All of the lessons can be found at https://rxjs-fundamentals.netlify.app. 6 | 7 | ## Node Requirements 8 | 9 | The recommended Node version for this course is `16.19.0`. You can use the [Node Version Manager or nvm](https://github.com/nvm-sh/nvm) to install the correct Node version. 10 | 11 | ## Getting Started 12 | 13 | You can get _everything_ started by running `npm start` and heading over to `http://localhost:1234`. If for some reason the combined command is giving you trouble, then you can run each of the following separate terminal windows for the same effect. 14 | 15 | There are four major components to this repository. 16 | 17 | - Sample applications: `npm run ui`. 18 | - API: `npm run api`. 19 | - Lessons: `npm run lessons` (Although, you probably just want to visit [the website](https://rxjs-fundamentals.netlify.app) for that.) 20 | - Tests: `npm test`. 21 | 22 | Alternatively, you can spin up both the sample applications and the server at the same time using `npm run both`. 23 | -------------------------------------------------------------------------------- /applications/loading-states/utilities.js: -------------------------------------------------------------------------------- 1 | import { tap, of, delay } from 'rxjs'; 2 | import './style.scss'; 3 | 4 | export const form = document.querySelector('form'); 5 | 6 | export const loadingStatus = document.getElementById('loading-status'); 7 | export const responseTimeField = document.getElementById('response-time'); 8 | export const showLoadingAfterField = 9 | document.getElementById('show-loading-after'); 10 | export const showLoadingForAtLeastField = document.getElementById( 11 | 'show-loading-for-at-least', 12 | ); 13 | 14 | export const fetchData = () => { 15 | const responseTime = +responseTimeField.value; 16 | const showAfter = +showLoadingAfterField.value; 17 | const showFor = +showLoadingForAtLeastField.value; 18 | 19 | return of(true).pipe( 20 | tap(() => console.log('Fetching…', responseTime)), 21 | delay(responseTime), 22 | tap(() => { 23 | console.log('Data: Recieved'); 24 | }), 25 | ); 26 | }; 27 | 28 | export const showLoading = (loading) => { 29 | console.log({ loading }); 30 | if (loading) { 31 | loadingStatus.innerHTML = '
Loading…
'; 32 | } else { 33 | loadingStatus.innerHTML = ''; 34 | } 35 | }; 36 | 37 | form.addEventListener('submit', (event) => { 38 | event.preventDefault(); 39 | }); 40 | -------------------------------------------------------------------------------- /applications/pokemon-paginated/script.js: -------------------------------------------------------------------------------- 1 | import { 2 | debounceTime, 3 | distinctUntilChanged, 4 | fromEvent, 5 | map, 6 | mergeMap, 7 | switchMap, 8 | tap, 9 | of, 10 | merge, 11 | from, 12 | filter, 13 | catchError, 14 | concat, 15 | take, 16 | EMPTY, 17 | retry, 18 | pluck, 19 | concatMap, 20 | } from 'rxjs'; 21 | 22 | import { fromFetch } from 'rxjs/fetch'; 23 | 24 | import { 25 | addResults, 26 | addResult, 27 | clearResults, 28 | endpointFor, 29 | search, 30 | form, 31 | } from '../pokemon/utilities'; 32 | 33 | const endpoint = 'http://localhost:3333/api/pokemon?delay=100'; 34 | 35 | export const getData = (url = endpoint) => { 36 | return fromFetch(url).pipe( 37 | mergeMap((response) => response.json()), 38 | mergeMap((response) => { 39 | const next$ = response.nextPage 40 | ? getData(endpoint + '&page=' + response.nextPage) 41 | : EMPTY; 42 | return concat(of(response.pokemon), next$); 43 | }), 44 | tap(console.log), 45 | filter(Boolean), 46 | tap(addResults), 47 | catchError((error) => { 48 | console.error(error); 49 | return EMPTY; 50 | }), 51 | ); 52 | }; 53 | 54 | const fetch$ = fromEvent(form, 'submit').pipe( 55 | switchMap(() => getData(endpoint)), 56 | ); 57 | 58 | fetch$.subscribe(); 59 | -------------------------------------------------------------------------------- /content/combining-operators.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Combining Obervables with Operators 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Some operators will subscribe to other observables. `takeUntil` and `skipUntil` are like that. 7 | 8 | ```js 9 | const startingTime = Date.now(); 10 | 11 | const firstTimer$ = timer(2000); 12 | const secondTimer$ = timer(7000); 13 | 14 | const example$ = interval(1000).pipe( 15 | skipUntil(firstTimer$), 16 | takeUntil(secondTimer$), 17 | ); 18 | 19 | example$.subscribe(() => console.log(Date.now() - startingTime)); 20 | 21 | // Logs: 2004, 3000, 4000, 5001, 6002 22 | ``` 23 | 24 | 25 | 26 | ## Exercise: Improving Our Counter 27 | 28 | Alright, so we have a few new tricks up our sleeves. 29 | 30 | - We know how to create observables that fire at regular intervals using `timer` and `interval`. 31 | - We know how to unsubscribe from an observable. 32 | - We know how to create observables from DOM events using `fromEvent`. 33 | 34 | Given the _very_ simple UI in `applications/counter-basic`, can you wire up this simple counter. 35 | 36 | It should be able to do the following: 37 | 38 | - Hitting the start button should create an `interval` observable that updates the value of the counter. 39 | - Hitting stop should… umm… stop the counter. 40 | -------------------------------------------------------------------------------- /content/basic-counter-solution.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic Counter (Solution) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Here is one possible solution. 7 | 8 | ```js 9 | startButtonClicks$.subscribe(() => { 10 | interval(1000).subscribe(setCount); 11 | }); 12 | ``` 13 | 14 | This works, but it has a lot of problems. Mostly, there isn't really a great way to stop it once it gets started. 15 | 16 | Could we do some hack like store a variable outside of the scope of the subscription and then unsubscribe from it when another button was hit? We _could_, but that kind of defeats the purpose. There is a better way to do this, but we just need to learn a little bit more first. 17 | 18 | **Disclaimer**: You should not do this something like the code below. 19 | 20 | ```js 21 | let interval$; 22 | 23 | startButtonClicks$.subscribe(() => { 24 | interval$ = interval(1000).subscribe(setCount); 25 | }); 26 | 27 | stopButtonClicks$.subscribe(() => { 28 | interval$.unsubscribe(); 29 | }); 30 | ``` 31 | 32 | It will work, but it's _not_ idiomatic. Let's table this and come back to it a little later. 33 | 34 | The answer is that we need some more tools. We need the ability to work with and manipulate observable streams. (For those of you that cannot live with not knowing how this story ends, you might want to take a look at [`switchMap`](/lessons/switch-map).) 35 | -------------------------------------------------------------------------------- /shared/lesson.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | RxJS Fundamentals Example 15 | 16 | 17 | 18 |
19 | 25 |
26 |

27 |
28 |
29 |
30 | 31 |
32 | 33 | 34 | -------------------------------------------------------------------------------- /utilities/dom-manpulation.js: -------------------------------------------------------------------------------- 1 | import isObject from 'lodash/isObject'; 2 | 3 | const setAttributes = (element, attributes) => { 4 | for (const [key, value] of Object.entries(attributes)) { 5 | element.setAttribute(key, value); 6 | } 7 | }; 8 | 9 | export const createElement = ( 10 | value, 11 | { tagName, classList, className, ...attributes } = { 12 | tagName: 'article', 13 | classList: [], 14 | }, 15 | ) => { 16 | const element = document.createElement(tagName || 'div'); 17 | 18 | if (Array.isArray(classList)) element.classList.add(...classList); 19 | if (className) element.className = className; 20 | 21 | setAttributes(element, attributes); 22 | 23 | if (typeof value !== 'string' && typeof value !== 'number') { 24 | value = JSON.stringify(value, null, 2); 25 | element.innerHTML = `
${value}
`; 26 | return element; 27 | } 28 | 29 | element.innerText = value; 30 | return element; 31 | }; 32 | 33 | export const addElementToDOM = (target, value, attributes) => { 34 | const element = createElement(value, attributes); 35 | target.appendChild(element); 36 | }; 37 | 38 | export const emptyElement = (target) => { 39 | target.innerText = ''; 40 | }; 41 | 42 | export const emptyElements = (targets) => { 43 | if (Array.isArray(targets)) return targets.forEach(emptyElement); 44 | if (isObject) return emptyElements(Object.values(targets)); 45 | }; 46 | -------------------------------------------------------------------------------- /applications/pokemon-data-enhancement/script.js: -------------------------------------------------------------------------------- 1 | import { identity } from 'lodash'; 2 | import { 3 | debounceTime, 4 | distinctUntilChanged, 5 | fromEvent, 6 | map, 7 | mergeMap, 8 | switchMap, 9 | tap, 10 | of, 11 | merge, 12 | from, 13 | pluck, 14 | take, 15 | exhaustMap, 16 | } from 'rxjs'; 17 | 18 | import { fromFetch } from 'rxjs/fetch'; 19 | 20 | import { 21 | renderPokemon, 22 | clearResults, 23 | endpoint, 24 | endpointFor, 25 | search, 26 | addDataToPokemon, 27 | form, 28 | } from '../pokemon/utilities'; 29 | 30 | const getPokemon = (searchTerm) => 31 | fromFetch(endpoint + searchTerm).pipe( 32 | mergeMap((response) => response.json()), 33 | ); 34 | 35 | const getAdditionalData = (pokemon) => 36 | fromFetch(endpointFor(pokemon.id)).pipe( 37 | mergeMap((response) => response.json()), 38 | ); 39 | 40 | const search$ = fromEvent(form, 'submit').pipe( 41 | map(() => search.value), 42 | exhaustMap((searchTerm) => 43 | getPokemon(searchTerm).pipe( 44 | pluck('pokemon'), 45 | mergeMap(identity), 46 | take(1), 47 | switchMap((pokemon) => { 48 | const pokemon$ = of(pokemon); 49 | 50 | const additionalData$ = getAdditionalData(pokemon).pipe( 51 | map((data) => ({ ...pokemon, data })), 52 | ); 53 | 54 | return merge(pokemon$, additionalData$); 55 | }), 56 | ), 57 | ), 58 | tap(renderPokemon), 59 | ); 60 | 61 | search$.subscribe(console.log); 62 | -------------------------------------------------------------------------------- /content/pixel-editor.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Pixel Editor 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | ## Drawing Lines 7 | 8 | ```js 9 | const mousedown$ = fromEvent(canvas, 'mousedown').pipe(map(getCoordinates)); 10 | const mousemove$ = fromEvent(canvas, 'mousemove').pipe(map(getCoordinates)); 11 | const mouseup$ = fromEvent(canvas, 'mouseup').pipe(map(getCoordinates)); 12 | 13 | const color$ = fromEvent(color, 'change', (event) => event.target.value).pipe( 14 | startWith(color.value), 15 | ); 16 | 17 | const isDrawingLine$ = mousedown$.pipe( 18 | switchMap(() => 19 | mousemove$.pipe( 20 | map(roundDownPoints), 21 | distinctUntilChanged(pointsAreEqual), 22 | takeUntil(mouseup$), 23 | ), 24 | ), 25 | withLatestFrom(color$), 26 | ); 27 | 28 | isDrawingLine$.subscribe(drawLine); 29 | ``` 30 | 31 | ## Drag and Drop Panel 32 | 33 | ```js 34 | const panelstart$ = fromEvent(panel, 'mousedown'); 35 | const panelmove$ = fromEvent(document, 'mousemove'); 36 | const panelend$ = fromEvent(document, 'mouseup'); 37 | 38 | const isMovingPanel$ = panelstart$.pipe( 39 | switchMap((start) => 40 | panelmove$.pipe( 41 | tap(() => panel.classList.add('moving')), 42 | map((event) => [event.x - start.offsetX, event.y - start.offsetY]), 43 | takeUntil(panelend$), 44 | finalize(() => panel.classList.remove('moving')), 45 | ), 46 | ), 47 | ); 48 | 49 | isMovingPanel$.subscribe(([x, y]) => { 50 | panel.style.top = y + 'px'; 51 | panel.style.left = x + 'px'; 52 | }); 53 | ``` 54 | -------------------------------------------------------------------------------- /applications/dog-facts-complete/script.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, of, timer, merge, NEVER } from 'rxjs'; 2 | import { fromFetch } from 'rxjs/fetch'; 3 | import { 4 | catchError, 5 | exhaustMap, 6 | mapTo, 7 | mergeMap, 8 | retry, 9 | startWith, 10 | switchMap, 11 | tap, 12 | pluck, 13 | } from 'rxjs/operators'; 14 | 15 | import { 16 | fetchButton, 17 | stopButton, 18 | clearError, 19 | clearFacts, 20 | addFacts, 21 | setError, 22 | } from '../dog-facts/utilities'; 23 | 24 | const endpoint = 'http://localhost:3333/api/facts'; 25 | 26 | const fetchData = () => 27 | fromFetch(endpoint).pipe( 28 | mergeMap((response) => { 29 | if (response.ok) { 30 | return response.json(); 31 | } else { 32 | throw new Error('Something went wrong!'); 33 | } 34 | }), 35 | tap(console.log), 36 | retry(4), 37 | catchError((error) => { 38 | console.error(error); 39 | return of({ error: 'The stream caught an error. Cool, right?' }); 40 | }), 41 | ); 42 | 43 | const fetch$ = fromEvent(fetchButton, 'click').pipe(mapTo(true)); 44 | const stop$ = fromEvent(stopButton, 'click').pipe(mapTo(false)); 45 | 46 | const factStream$ = merge(fetch$, stop$).pipe( 47 | startWith(false), 48 | switchMap((shouldFetch) => { 49 | return shouldFetch 50 | ? timer(0, 5000).pipe( 51 | tap(() => clearError()), 52 | tap(() => clearFacts()), 53 | exhaustMap(fetchData), 54 | ) 55 | : NEVER; 56 | }), 57 | ); 58 | 59 | factStream$.subscribe(addFacts); 60 | -------------------------------------------------------------------------------- /content/from-event-exercise.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: fromEvent (Exercise) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | `fromEvent` allows you to create observables from event listeners. This might seem a little bit silly at first, but we'll see that this allows for us to do some sophisticated things with events later on. 7 | 8 | We can take an event listener that looks like this: 9 | 10 | ```js 11 | const button = document.querySelector('button'); 12 | 13 | button.addEventListener('click', (event) => { 14 | console.log(event); 15 | }); 16 | ``` 17 | 18 | And we can use `fromEvent` instead. 19 | 20 | ```js 21 | const buttonClicks$ = fromEvent(button, 'click'); 22 | 23 | buttonClicks$.subscribe(console.log); 24 | ``` 25 | 26 | The one thing that you'll notice is that you need to subscribe to the observable in order for the event listener to be registered. 27 | 28 | You can even pass it a callback function if you want to format the event. 29 | 30 | ```js 31 | const inputChanges$ = fromEvent(input, 'input', (event) => { 32 | event.target.value; 33 | }); 34 | ``` 35 | 36 | ## Your Mission 37 | 38 | In [`applications/from-event`][exercise], we have an incredibly basic example. Can you use `fromEvent` as an alternative to an event listener? 39 | 40 | - Use `fromEvent` to create an observable that streams click events. 41 | - Subscribe to that observable. 42 | - Use `addMessageToDOM` to add a useless message to the DOM whenever the stream emits a value. 43 | 44 | [exercise]: https://github.com/stevekinney/rxjs-fundamentals/tree/master/applications/from-event 45 | -------------------------------------------------------------------------------- /applications/mapping-complete/script.js: -------------------------------------------------------------------------------- 1 | import { of, from, interval, fromEvent, merge, NEVER } from 'rxjs'; 2 | import { 3 | pluck, 4 | concatMap, 5 | take, 6 | map, 7 | combineLatestAll, 8 | startWith, 9 | mergeMap, 10 | shareReplay, 11 | mapTo, 12 | tap, 13 | switchMap, 14 | share, 15 | } from 'rxjs/operators'; 16 | 17 | import { 18 | getCharacter, 19 | render, 20 | startButton, 21 | pauseButton, 22 | setStatus, 23 | } from './utilities'; 24 | 25 | // const example$ = of('John', 'Paul', 'George', 'Ringo').pipe( 26 | // exhaustMap((beatle, index) => 27 | // interval(index * 1000).pipe( 28 | // take(4), 29 | // map((i) => `${beatle} ${i}`), 30 | // ), 31 | // ), 32 | // ); 33 | 34 | // const example$ = of('John', 'Paul', 'George', 'Ringo').pipe( 35 | // map((beatle, index) => 36 | // interval(index * 1000).pipe( 37 | // startWith('(Not Started)'), 38 | // take(5), 39 | // map((i) => `${beatle} ${i}`), 40 | // ), 41 | // ), 42 | // combineLatestAll(), 43 | // ); 44 | 45 | // example$.subscribe(render); 46 | 47 | const characters$ = interval(1000).pipe(mergeMap(getCharacter)); 48 | 49 | const start$ = fromEvent(startButton, 'click').pipe(mapTo(true)); 50 | const pause$ = fromEvent(pauseButton, 'click').pipe(mapTo(false)); 51 | 52 | const isRunning$ = merge(start$, pause$).pipe( 53 | startWith(false), 54 | tap(setStatus), 55 | switchMap((isRunning) => (isRunning ? characters$ : NEVER)), 56 | pluck('name'), 57 | tap(render), 58 | ); 59 | 60 | isRunning$.subscribe(); 61 | -------------------------------------------------------------------------------- /content/combining-operators-solution.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Combining Obervables with Operators (Solution) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | When last we worked with our counter, we had something like this. 7 | 8 | ```js 9 | let interval$; 10 | 11 | startButtonClicks$.subscribe(() => { 12 | interval$ = interval(1000).subscribe((value) => (count.value = value)); 13 | }); 14 | 15 | stopButtonClicks$.subscribe(() => { 16 | interval$.unsubscribe(); 17 | }); 18 | ``` 19 | 20 | But, now we have `skipUntil` and `takeUntil` at our disposal. What would it look like if we refactored our timer to _not_ use a global variable? 21 | 22 | We could try something like this: 23 | 24 | ```js 25 | const start$ = fromEvent(start, 'click'); 26 | const pause$ = fromEvent(pause, 'click'); 27 | 28 | const counter$ = interval(1000).pipe(skipUntil(start$), takeUntil(pause$)); 29 | ``` 30 | 31 | You'll notice that our count is a little weird. The `interval` is still going. We can use `scan` to fix this. 32 | 33 | ```js 34 | const start$ = fromEvent(start, 'click'); 35 | const pause$ = fromEvent(pause, 'click'); 36 | 37 | const counter$ = interval(1000).pipe( 38 | skipUntil(start$), 39 | scan((total) => total + 1, 0), 40 | takeUntil(pause$), 41 | ); 42 | ``` 43 | 44 | ## It Still Has Some Issues 45 | 46 | We've made our code cleaner and we've done things in a more idiomatic way, but we're not struggling with the fact that we have a single-use counter. There are a few ways that we can deal with this, but none of them are great. So, let's revisit this later, shall we? 47 | -------------------------------------------------------------------------------- /shared/application.scss: -------------------------------------------------------------------------------- 1 | @import './colors.scss'; 2 | 3 | body { 4 | background-color: lighten($teal, 50%); 5 | } 6 | 7 | .application { 8 | margin: 2em; 9 | background-color: white; 10 | padding: 24px; 11 | border: 4px solid $blue; 12 | box-shadow: 4px 4px 8px darken($blue, 50%); 13 | } 14 | 15 | .controls { 16 | display: flex; 17 | flex-direction: column; 18 | gap: 18px; 19 | } 20 | 21 | .control-set { 22 | display: flex; 23 | gap: 12px; 24 | justify-content: center; 25 | } 26 | 27 | .count { 28 | font-family: 'Press Start 2P', monospace; 29 | font-size: 96px; 30 | text-align: center; 31 | } 32 | 33 | .notifications { 34 | display: flex; 35 | gap: 12px; 36 | section { 37 | width: 100%; 38 | } 39 | article { 40 | background-color: lighten($blue, 10%); 41 | border: 2px solid $blue; 42 | margin-bottom: 1em; 43 | padding: 1em 1em; 44 | box-shadow: 4px 4px 4px; 45 | } 46 | } 47 | 48 | .stream-element { 49 | border: 1px solid black; 50 | padding: 12px; 51 | margin: 6px; 52 | pre { 53 | color: $fuschia; 54 | } 55 | } 56 | 57 | .stream-first { 58 | background-color: $coral; 59 | } 60 | 61 | .stream-second { 62 | background-color: $blue; 63 | } 64 | 65 | .stream-combined { 66 | background-color: $fuschia; 67 | } 68 | 69 | .stream-john { 70 | background-color: $coral; 71 | } 72 | 73 | .stream-paul { 74 | background-color: $blue; 75 | } 76 | 77 | .stream-george { 78 | background-color: $fuschia; 79 | } 80 | 81 | .stream-ringo { 82 | background-color: $green; 83 | } 84 | 85 | #deep-thoughts .column { 86 | border-width: 0; 87 | } 88 | 89 | #deep-thought-status { 90 | color: lighten($blue, 10%); 91 | font-style: italic; 92 | } -------------------------------------------------------------------------------- /applications/pixel-editor/script.js: -------------------------------------------------------------------------------- 1 | import { fromEvent } from 'rxjs'; 2 | import { 3 | switchMap, 4 | map, 5 | takeUntil, 6 | startWith, 7 | distinctUntilChanged, 8 | withLatestFrom, 9 | tap, 10 | finalize, 11 | } from 'rxjs/operators'; 12 | 13 | import { 14 | canvas, 15 | color, 16 | panel, 17 | roundDown, 18 | roundDownPoints, 19 | pointsAreEqual, 20 | getCoordinates, 21 | drawLine, 22 | } from './utilities'; 23 | 24 | const mousedown$ = fromEvent(canvas, 'mousedown').pipe(map(getCoordinates)); 25 | const mousemove$ = fromEvent(canvas, 'mousemove').pipe(map(getCoordinates)); 26 | const mouseup$ = fromEvent(canvas, 'mouseup').pipe(map(getCoordinates)); 27 | 28 | const color$ = fromEvent(color, 'change', (event) => event.target.value).pipe( 29 | startWith(color.value), 30 | ); 31 | 32 | const isDrawingLine$ = mousedown$.pipe( 33 | switchMap(() => 34 | mousemove$.pipe( 35 | map(roundDownPoints), 36 | distinctUntilChanged(pointsAreEqual), 37 | takeUntil(mouseup$), 38 | ), 39 | ), 40 | withLatestFrom(color$), 41 | ); 42 | 43 | isDrawingLine$.subscribe(drawLine); 44 | 45 | const panelstart$ = fromEvent(panel, 'mousedown'); 46 | const panelmove$ = fromEvent(document, 'mousemove'); 47 | const panelend$ = fromEvent(document, 'mouseup'); 48 | 49 | const isMovingPanel$ = panelstart$.pipe( 50 | switchMap((start) => 51 | panelmove$.pipe( 52 | tap(() => panel.classList.add('moving')), 53 | map((event) => [event.x - start.offsetX, event.y - start.offsetY]), 54 | takeUntil(panelend$), 55 | finalize(() => panel.classList.remove('moving')), 56 | ), 57 | ), 58 | ); 59 | 60 | isMovingPanel$.subscribe(([x, y]) => { 61 | panel.style.top = y + 'px'; 62 | panel.style.left = x + 'px'; 63 | }); 64 | -------------------------------------------------------------------------------- /shared/base.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | RxJS Fundamentals Example 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 |
23 | 24 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /applications/mapping/utilities.js: -------------------------------------------------------------------------------- 1 | import { fromEvent } from 'rxjs'; 2 | import { 3 | createElement, 4 | addElementToDOM, 5 | emptyElement, 6 | } from '../../utilities/dom-manpulation'; 7 | import { characters } from './star-wars-characters.json'; 8 | 9 | export const startButton = document.getElementById('start'); 10 | export const pauseButton = document.getElementById('pause'); 11 | export const clearButton = document.getElementById('clear'); 12 | export const status = document.getElementById('status'); 13 | export const output = document.getElementById('output'); 14 | 15 | export const getCharacter = (id) => { 16 | return new Promise((resolve, reject) => { 17 | const character = characters.find((character) => character.id === id); 18 | if (character) { 19 | resolve(character); 20 | } else { 21 | reject({ error: 'Not found.' }); 22 | } 23 | }); 24 | }; 25 | 26 | export const setStatus = (isRunning) => { 27 | if (isRunning) { 28 | status.innerText = 'Running…'; 29 | startButton.disabled = true; 30 | pauseButton.disabled = false; 31 | } else { 32 | status.innerText = 'Paused.'; 33 | startButton.disabled = false; 34 | pauseButton.disabled = true; 35 | } 36 | }; 37 | 38 | const beatles = ['John', 'Paul', 'George', 'Ringo']; 39 | 40 | const findBeatle = (value) => { 41 | for (const beatle of beatles) { 42 | const match = String(value).match(beatle); 43 | if (match) return match[0].toLowerCase(); 44 | } 45 | return false; 46 | }; 47 | 48 | export const render = (value) => { 49 | const classList = ['stream-element']; 50 | const beatle = findBeatle(value); 51 | 52 | console.log(value); 53 | 54 | if (beatle) classList.push('stream-' + beatle); 55 | 56 | addElementToDOM(output, value, { classList }); 57 | }; 58 | 59 | fromEvent(clearButton, 'click').subscribe(() => emptyElement(output)); 60 | -------------------------------------------------------------------------------- /applications/mapping-complete/utilities.js: -------------------------------------------------------------------------------- 1 | import { fromEvent } from 'rxjs'; 2 | import { 3 | createElement, 4 | addElementToDOM, 5 | emptyElement, 6 | } from '../../utilities/dom-manpulation'; 7 | import { characters } from './star-wars-characters.json'; 8 | 9 | export const startButton = document.getElementById('start'); 10 | export const pauseButton = document.getElementById('pause'); 11 | export const clearButton = document.getElementById('clear'); 12 | export const status = document.getElementById('status'); 13 | export const output = document.getElementById('output'); 14 | 15 | export const getCharacter = (id) => { 16 | return new Promise((resolve, reject) => { 17 | const character = characters.find((character) => character.id === id); 18 | if (character) { 19 | resolve(character); 20 | } else { 21 | reject({ error: 'Not found.' }); 22 | } 23 | }); 24 | }; 25 | 26 | export const setStatus = (isRunning) => { 27 | if (isRunning) { 28 | status.innerText = 'Running…'; 29 | startButton.disabled = true; 30 | pauseButton.disabled = false; 31 | } else { 32 | status.innerText = 'Paused.'; 33 | startButton.disabled = false; 34 | pauseButton.disabled = true; 35 | } 36 | }; 37 | 38 | const beatles = ['John', 'Paul', 'George', 'Ringo']; 39 | 40 | const findBeatle = (value) => { 41 | for (const beatle of beatles) { 42 | const match = String(value).match(beatle); 43 | if (match) return match[0].toLowerCase(); 44 | } 45 | return false; 46 | }; 47 | 48 | export const render = (value) => { 49 | const classList = ['stream-element']; 50 | const beatle = findBeatle(value); 51 | 52 | console.log(value); 53 | 54 | if (beatle) classList.push('stream-' + beatle); 55 | 56 | addElementToDOM(output, value, { classList }); 57 | }; 58 | 59 | fromEvent(clearButton, 'click').subscribe(() => emptyElement(output)); 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rxjs-fundamentals", 3 | "version": "0.0.0", 4 | "author": "Steve Kinney ", 5 | "scripts": { 6 | "start": "npm run all", 7 | "ui": "parcel index.html lessons/**/*.html applications/**/*.html --no-cache", 8 | "api": "node api/server.js", 9 | "both": "concurrently \"npm:ui\" \"npm:api\"", 10 | "test": "jest --watch", 11 | "build": "npm run build-lessons && npm run build-site", 12 | "build-site": "parcel build index.html lessons/**/*.html applications/**/*.html", 13 | "build-lessons": "eleventy --config=.eleventy.cjs", 14 | "lessons": "npm run build-lessons -- --watch --quiet", 15 | "all": "concurrently \"npm:ui\" \"npm:api\" \"npm:lessons\"" 16 | }, 17 | "type": "module", 18 | "devDependencies": { 19 | "@11ty/eleventy": "^0.12.1", 20 | "@babel/core": "^7.16.0", 21 | "@babel/plugin-transform-runtime": "^7.16.0", 22 | "@babel/polyfill": "^7.12.1", 23 | "@babel/preset-env": "^7.16.0", 24 | "@parcel/babel-preset-env": "^2.0.1", 25 | "@parcel/transformer-sass": "^2.0.1", 26 | "babel-eslint": "^10.1.0", 27 | "babel-jest": "^27.3.1", 28 | "concurrently": "^6.4.0", 29 | "core-js": "^3.19.1", 30 | "eslint": "^8.2.0", 31 | "eslint-config-prettier": "^8.3.0", 32 | "eslint-plugin-import": "^2.25.3", 33 | "jest": "^27.3.1", 34 | "parcel": "^2.0.1", 35 | "postcss-fontpath": "^1.0.0", 36 | "posthtml-doctype": "^1.1.1", 37 | "posthtml-extend": "^0.6.0", 38 | "posthtml-prism": "^1.0.4", 39 | "prettier": "^2.4.1" 40 | }, 41 | "dependencies": { 42 | "base-64": "^1.0.0", 43 | "body-parser": "^1.19.0", 44 | "cors": "^2.8.5", 45 | "express": "^4.17.1", 46 | "lodash": "^4.17.21", 47 | "markdown-it": "^12.2.0", 48 | "rxjs": "^7.4.0" 49 | }, 50 | "source": [ 51 | "index.html" 52 | ], 53 | "jest": { 54 | "transform": { 55 | "^.+\\.jsx?$": "babel-jest" 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /content/autocomplete.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Autocomplete 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Let's start by listening to the search field. 7 | 8 | ```js 9 | const search$ = fromEvent(search, 'input').pipe( 10 | map((event) => event.target.value), 11 | ); 12 | 13 | search$.subscribe(console.log); 14 | ``` 15 | 16 | We have a search endpoint: `http://localhost:3333/api/pokemon/search/pika`; 17 | 18 | We can start with the simplest example: 19 | 20 | ```js 21 | const search$ = fromEvent(search, 'input').pipe( 22 | map((event) => event.target.value), 23 | mergeMap((searchTerm) => 24 | fromFetch(endpoint + searchTerm + '?delay=5000&chaos=true').pipe( 25 | mergeMap((response) => response.json()), 26 | ), 27 | ), 28 | tap(clearResults), 29 | map((response) => response.pokemon), 30 | tap(addResults), 31 | ); 32 | ``` 33 | 34 | Try it out. What's not working? It keeps switching out and reloading pieces. 35 | 36 | Do we have any hypotheses here? 37 | 38 | Yea, totally… a `switchMap` will work: 39 | 40 | ```js 41 | const search$ = fromEvent(search, 'input').pipe( 42 | map((event) => event.target.value), 43 | switchMap((searchTerm) => 44 | fromFetch(endpoint + searchTerm + '?delay=5000&chaos=true').pipe( 45 | mergeMap((response) => response.json()), 46 | ), 47 | ), 48 | tap(clearResults), 49 | map((response) => response.pokemon), 50 | tap(addResults), 51 | ); 52 | ``` 53 | 54 | But we're also firing off a new keystroke every time as well, that seems to be a bit much. 55 | 56 | How can we slow that down? 57 | 58 | ```js 59 | const search$ = fromEvent(search, 'input').pipe( 60 | debounceTime(300), 61 | map((event) => event.target.value), 62 | distinctUntilChanged(), 63 | switchMap((searchTerm) => 64 | fromFetch(endpoint + searchTerm + '?delay=5000&chaos=true').pipe( 65 | mergeMap((response) => response.json()), 66 | ), 67 | ), 68 | tap(clearResults), 69 | map((response) => response.pokemon), 70 | tap(addResults), 71 | ); 72 | ``` 73 | -------------------------------------------------------------------------------- /content/creating-observables-exercise.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Creating Observables (Exercise) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | We can write some tests to wrap our head around how the two, most basic ways to create observables work. Let's start with two incredibly simple tests. 7 | 8 | Things to look out for here: 9 | 10 | - We set up an empty array. 11 | - We use our array to store all of the values that were emitted by our observable. 12 | - We then verify that the contents of our array match what we're expecting. 13 | 14 | ```js 15 | import { from, of } from 'rxjs'; 16 | 17 | describe('Basic Observables', () => { 18 | describe(of, () => { 19 | it('should create an observable from its arguments', () => { 20 | const example$ = of(1, 2, 3, 4); 21 | const result = []; 22 | 23 | example$.subscribe((value) => result.push(value)); 24 | 25 | expect(result).toEqual([1, 2, 3, 4]); 26 | }); 27 | }); 28 | 29 | describe(from, () => { 30 | it('should create an observable', () => { 31 | const example$ = from([1, 2, 3, 4]); 32 | const result = []; 33 | 34 | example$.subscribe((value) => result.push(value)); 35 | 36 | expect(result).toEqual([1, 2, 3, 4]); 37 | }); 38 | }); 39 | }); 40 | ``` 41 | 42 | ## Exercise 43 | 44 | We have a set of tests in [`exercises/creating-observables.test.js`][exercise]. You can run just these tests using the the following. 45 | 46 | `npm test creating` will scope Jest down to _just_ the appropriate tests. 47 | 48 | **Your Mission**: Un-skip each test and make sure they pass. 49 | 50 | Some things that you will want to keep in mind: 51 | 52 | - `of` can be used for any set of values. 53 | - `from` can only be used for iterable objects (e.g. arrays, generators) and observable-like objects (e.g. promises). 54 | - Promises are asynchronous. So, we only want to run our expecations after the observable has completed. 55 | 56 | [exercise]: https://github.com/stevekinney/rxjs-fundamentals/blob/master/exercises/creating-observables.test.js 57 | -------------------------------------------------------------------------------- /content/basic-operators-solution.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic Operators (Solution) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Here is the solution to the suite of tests where we explored how to use some basic operators. 7 | 8 | ```js 9 | describe('Basic Operators', () => { 10 | it('should take the first 5 values and map them to the word "DINOSAUR"', async () => { 11 | const observable$ = of(1, 2, 3, 4, 5, 6, 7).pipe( 12 | take(5), 13 | mapTo('DINOSAUR'), 14 | ); 15 | 16 | return expect(await getResult(observable$)).toEqual([ 17 | 'DINOSAUR', 18 | 'DINOSAUR', 19 | 'DINOSAUR', 20 | 'DINOSAUR', 21 | 'DINOSAUR', 22 | ]); 23 | }); 24 | 25 | it('should skip the first 5 values and double last two', async () => { 26 | const observable$ = of(1, 2, 3, 4, 5, 6, 7).pipe( 27 | skip(5), 28 | map((n) => n * 2), 29 | ); 30 | 31 | return expect(await getResult(observable$)).toEqual([12, 14]); 32 | }); 33 | 34 | it('should emit the square of every even number in the stream', async () => { 35 | const observable$ = of(1, 2, 3, 4, 5, 6, 7).pipe( 36 | filter((x) => x % 2 === 0), 37 | map((n) => n * n), 38 | ); 39 | 40 | return expect(await getResult(observable$)).toEqual([4, 16, 36]); 41 | }); 42 | 43 | it('should sum of the total of all of the Fibonacci numbers under 200', async () => { 44 | const observable$ = from(fibonacci()).pipe( 45 | takeWhile((n) => n < 200), 46 | reduce((total, n) => total + n, 0), 47 | ); 48 | 49 | expect(await getResult(observable$)).toEqual([376]); 50 | }); 51 | 52 | it('should merge each object emited into a single object, emitting each state along the way', async () => { 53 | const observable$ = of( 54 | { isRunning: true }, 55 | { currentSpeed: 100 }, 56 | { currentSpeed: 200 }, 57 | { distance: 500 }, 58 | ).pipe(scan((state, next) => ({ ...state, ...next }), {})); 59 | 60 | expect(await getResult(observable$)).toEqual([ 61 | { isRunning: true }, 62 | { isRunning: true, currentSpeed: 100 }, 63 | { isRunning: true, currentSpeed: 200 }, 64 | { isRunning: true, currentSpeed: 200, distance: 500 }, 65 | ]); 66 | }); 67 | }); 68 | ``` 69 | -------------------------------------------------------------------------------- /content/first-and-last-name.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: First and Last Name (Exercise) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Functions and operators used: 7 | 8 | - `merge` 9 | - `startWith` 10 | - `map` 11 | - `combineLatestAll` 12 | 13 | --- 14 | 15 | Let's start with something simple here. 16 | 17 | ```js 18 | fromEvent(firstNameInput, 'input').subscribe(console.log); 19 | fromEvent(lastNameInput, 'input').subscribe(console.log); 20 | ``` 21 | 22 | This works, but its not very DRY and it actually doesn't really work nearly as well as we'd like. 23 | 24 | Let's merge the streams into one. 25 | 26 | ```js 27 | import { fromEvent, merge } from 'rxjs'; 28 | 29 | const firstName = document.getElementById('first-name'); 30 | const lastName = document.getElementById('last-name'); 31 | 32 | const firstName$ = fromEvent(firstName, 'keyup'); 33 | const lastName$ = fromEvent(lastName, 'keyup'); 34 | 35 | merge(firstName$, lastName$).subscribe(console.log); 36 | ``` 37 | 38 | Alright, now we don't need the `KeyboardEvent`, we need the actual value of that input. 39 | 40 | ```js 41 | import { combineLatest, fromEvent, map, merge, startWith } from 'rxjs'; 42 | 43 | const firstName = document.getElementById('first-name'); 44 | const lastName = document.getElementById('last-name'); 45 | 46 | const firstName$ = fromEvent(firstName, 'keyup', (e) => e.target.value).pipe( 47 | startWith(''), 48 | ); 49 | 50 | const lastName$ = fromEvent(lastName, 'keyup', (e) => e.target.value).pipe( 51 | startWith(''), 52 | ); 53 | 54 | combineLatest(firstName$, lastName$) 55 | .pipe(map(([first, last]) => `${first} ${last}`)) 56 | .subscribe(console.log); 57 | ``` 58 | 59 | ```js 60 | import { combineLatest, fromEvent, map, startWith } from 'rxjs'; 61 | 62 | const firstName = document.getElementById('first-name'); 63 | const lastName = document.getElementById('last-name'); 64 | 65 | const firstName$ = fromEvent(firstName, 'keyup', (e) => e.target.value).pipe( 66 | startWith(firstName.value), 67 | ); 68 | 69 | const lastName$ = fromEvent(lastName, 'keyup', (e) => e.target.value).pipe( 70 | startWith(lastName.value), 71 | ); 72 | 73 | combineLatest(firstName$, lastName$) 74 | .pipe(map(([first, last]) => `${first} ${last}`)) 75 | .subscribe(console.log); 76 | ``` 77 | -------------------------------------------------------------------------------- /api/views/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Pokemon 5 | 6 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 |

Pokemon

24 |
25 | 26 |
27 |

28 | This is a quick and simple API for the example applications in Steve's 29 | course on RxJS. 30 |

31 | 32 |

Endpoints

33 | 34 | 54 | 55 | 63 |
64 | 65 | 66 | 67 |
71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /shared/fonts.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: 'Averia'; 3 | src: url('/shared/fonts/AveriaSerifLibre-Bold.ttf') format('truetype'); 4 | font-style: normal; 5 | font-weight: 700; 6 | font-display: swap; 7 | } 8 | 9 | @font-face { 10 | font-family: 'Averia'; 11 | src: url('/shared/fonts/AveriaSerifLibre-BoldItalic.ttf') format('truetype'); 12 | font-style: italic; 13 | font-weight: 700; 14 | font-display: swap; 15 | } 16 | 17 | @font-face { 18 | font-family: 'Averia'; 19 | src: url('/shared/fonts/AveriaSerifLibre-Italic.ttf') format('truetype'); 20 | font-style: italic; 21 | font-weight: 400; 22 | font-display: swap; 23 | } 24 | 25 | @font-face { 26 | font-family: 'Averia'; 27 | src: url('/shared/fonts/AveriaSerifLibre-Light.ttf') format('truetype'); 28 | font-style: normal; 29 | font-weight: 300; 30 | font-display: swap; 31 | } 32 | 33 | @font-face { 34 | font-family: 'Averia'; 35 | src: url('/shared/fonts/AveriaSerifLibre-LightItalic.ttf') format('truetype'); 36 | font-style: italic; 37 | font-weight: 300; 38 | font-display: swap; 39 | } 40 | 41 | @font-face { 42 | font-family: 'Averia'; 43 | src: url('/shared/fonts/AveriaSerifLibre-Regular.ttf') format('truetype'); 44 | font-style: normal; 45 | font-weight: 400; 46 | font-display: swap; 47 | } 48 | 49 | @font-face { 50 | font-family: 'Courier Prime'; 51 | src: url('/shared/fonts/CourierPrime-Bold.ttf') format('truetype'); 52 | font-style: normal; 53 | font-weight: 700; 54 | font-display: swap; 55 | } 56 | 57 | @font-face { 58 | font-family: 'Courier Prime'; 59 | src: url('/shared/fonts/CourierPrime-BoldItalic.ttf') format('truetype'); 60 | font-style: italic; 61 | font-weight: 700; 62 | font-display: swap; 63 | } 64 | 65 | @font-face { 66 | font-family: 'Courier Prime'; 67 | src: url('/shared/fonts/CourierPrime-Italic.ttf') format('truetype'); 68 | font-style: italic; 69 | font-weight: 400; 70 | font-display: swap; 71 | } 72 | 73 | @font-face { 74 | font-family: 'Courier Prime'; 75 | src: url('/shared/fonts/CourierPrime-Regular.ttf') format('truetype'); 76 | font-style: normal; 77 | font-weight: 400; 78 | font-display: swap; 79 | } 80 | 81 | @font-face { 82 | font-family: 'Press Start 2P'; 83 | src: url('/shared/fonts/Press-Start-2P.ttf') format('truetype'); 84 | font-style: normal; 85 | font-weight: 400; 86 | font-display: swap; 87 | } 88 | -------------------------------------------------------------------------------- /applications/pokemon/utilities.js: -------------------------------------------------------------------------------- 1 | import { EMPTY, NEVER } from 'rxjs'; 2 | import { addElementToDOM } from '../../utilities/dom-manpulation'; 3 | import './style.scss'; 4 | 5 | export const form = document.getElementById('fetch-form'); 6 | export const search = document.getElementById('search'); 7 | export const submit = document.getElementById('fetch'); 8 | export const results = document.getElementById('results'); 9 | export const pokemonView = document.getElementById('pokemon'); 10 | 11 | export const clearResults = () => (results.innerText = ''); 12 | export const addResults = (response) => response.forEach(addResult); 13 | export const addResult = (result) => { 14 | addElementToDOM(results, result); 15 | }; 16 | 17 | export const renderPokemon = (pokemon) => { 18 | pokemonView.innerHTML = ` 19 |

${pokemon.name}

20 |

${pokemon.classification}

21 |
${renderData(pokemon)}
22 | `; 23 | }; 24 | 25 | const renderData = ({ data }) => { 26 | if (!data) return 'Loading additional data…'; 27 | 28 | return ` 29 |

Abilities

30 | 31 | 34 | 35 | 36 | 37 | 38 | 41 | 44 | 47 | 50 | 51 | 52 | 53 | 54 | 57 | 60 | 63 | 66 | 67 | 68 |
39 | Hit Points 40 | 42 | Height 43 | 45 | Weight 46 | 48 | Speed 49 |
55 | ${data.hp} 56 | 58 | ${data.height_m} 59 | 61 | ${data.weight_kg} 62 | 64 | ${data.speed} 65 |
69 | `; 70 | }; 71 | 72 | export const addDataToPokemon = (pokemon, data) => { 73 | return data; 74 | }; 75 | 76 | export const endpoint = 'http://localhost:3333/api/pokemon/search/'; 77 | export const endpointFor = (id) => 78 | 'http://localhost:3333/api/pokemon/' + id + '?delay=2000'; 79 | 80 | form.addEventListener('submit', (event) => event.preventDefault()); 81 | -------------------------------------------------------------------------------- /exercises/basic-operators.test.js: -------------------------------------------------------------------------------- 1 | import { from, of } from 'rxjs'; 2 | import { 3 | take, 4 | skip, 5 | filter, 6 | map, 7 | mapTo, 8 | reduce, 9 | scan, 10 | takeWhile, 11 | } from 'rxjs/operators'; 12 | import { fibonacci } from '../utilities/fibonacci'; 13 | 14 | /** 15 | * Returns all of the values emitted by an observable as an array. 16 | * I'm tired of typing this out for every test. 17 | */ 18 | const getResult = async (observable) => { 19 | return new Promise((resolve, reject) => { 20 | const result = []; 21 | const subscription = observable.subscribe({ 22 | next: (value) => result.push(value), 23 | error: reject, 24 | complete: () => { 25 | resolve(result); 26 | subscription.unsubscribe(); 27 | }, 28 | }); 29 | }); 30 | }; 31 | 32 | describe('Basic Operators', () => { 33 | it.skip('should take the first 5 values and map them to the word "DINOSAUR"', async () => { 34 | const observable$ = of(1, 2, 3, 4, 5, 6, 7).pipe(); 35 | 36 | return expect(await getResult(observable$)).toEqual([ 37 | 'DINOSAUR', 38 | 'DINOSAUR', 39 | 'DINOSAUR', 40 | 'DINOSAUR', 41 | 'DINOSAUR', 42 | ]); 43 | }); 44 | 45 | it.skip('should skip the first 5 values and double last two', async () => { 46 | const observable$ = of(1, 2, 3, 4, 5, 6, 7).pipe(); 47 | 48 | return expect(await getResult(observable$)).toEqual([12, 14]); 49 | }); 50 | 51 | it.skip('should emit the square of every even number in the stream', async () => { 52 | const observable$ = of(1, 2, 3, 4, 5, 6, 7).pipe(); 53 | 54 | return expect(await getResult(observable$)).toEqual([4, 16, 36]); 55 | }); 56 | 57 | it.skip('should sum of the total of all of the Fibonacci numbers under 200', async () => { 58 | const observable$ = from(fibonacci()).pipe(); 59 | 60 | expect(await getResult(observable$)).toEqual([376]); 61 | }); 62 | 63 | it.skip('should merge each object emited into a single object, emitting each state along the way', async () => { 64 | const observable$ = of( 65 | { isRunning: true }, 66 | { currentSpeed: 100 }, 67 | { currentSpeed: 200 }, 68 | { distance: 500 }, 69 | ).pipe(); 70 | 71 | expect(await getResult(observable$)).toEqual([ 72 | { isRunning: true }, 73 | { isRunning: true, currentSpeed: 100 }, 74 | { isRunning: true, currentSpeed: 200 }, 75 | { isRunning: true, currentSpeed: 200, distance: 500 }, 76 | ]); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /content/basic-async-solution.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic Async (Solution) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Making API calls is one of most common asynchornous actions that we do in our client-side applications. Naturally, RxJS comes with `fromFetch` to assist us when we're woring with APIs. 7 | 8 | ```js 9 | import { of } from 'rxjs'; 10 | import { map } from 'rxjs/operators'; 11 | import './playground'; 12 | 13 | import { fromFetch } from 'rxjs/fetch'; 14 | 15 | export const example$ = fromFetch('http://localhost:3333/api/pokemon').pipe( 16 | map((response) => response.json()), 17 | ); 18 | ``` 19 | 20 | But, we need a `switchMap` to actually make things work. 21 | 22 | ```js 23 | import { switchMap } from 'rxjs/operators'; 24 | import { fromFetch } from 'rxjs/fetch'; 25 | 26 | export const example$ = fromFetch('http://localhost:3333/api/pokemon').pipe( 27 | switchMap((response) => response.json()), 28 | ); 29 | ``` 30 | 31 | You can propably use a `mergeMap` but, if we're being honest with ourselves, we only want the last one going through the pipe. 32 | 33 | We can improve this a little more with some error handling. 34 | 35 | Using a special `flakiness` query parameter will make the API fail for us. 36 | 37 | - `flakiness=1` will have it fail all of the time. 38 | - `flakiness=2` will have it fail half of the time. 39 | - `flakiness=3` will have it fail one third of the time. 40 | 41 | ```js 42 | import { catchError, of, switchMap } from 'rxjs'; 43 | import { fromFetch } from 'rxjs/fetch'; 44 | 45 | export const example$ = fromFetch( 46 | 'http://localhost:3333/api/pokemon?flakiness=1', 47 | ).pipe( 48 | switchMap((response) => { 49 | if (response.ok) { 50 | return response.json(); 51 | } else { 52 | return of({ error: true, message: response.status }); 53 | } 54 | }), 55 | catchError((error) => { 56 | console.error(error); 57 | return of({ error: true, message: error.message }); 58 | }), 59 | ); 60 | ``` 61 | 62 | Okay, but what if we wanted to retry? 63 | 64 | ```js 65 | import { catchError, of, retry, switchMap, tap } from 'rxjs'; 66 | import { fromFetch } from 'rxjs/fetch'; 67 | 68 | export const example$ = fromFetch( 69 | 'http://localhost:3333/api/pokemon?flakiness=3', 70 | ).pipe( 71 | tap((x) => console.log('Trying', x)), 72 | switchMap((response) => { 73 | if (response.ok) { 74 | return response.json(); 75 | } else { 76 | throw new Error(`${response.status}`); 77 | } 78 | }), 79 | retry(5), 80 | catchError((error) => { 81 | console.error(error.message); 82 | return of({ error: true, message: error }); 83 | }), 84 | ); 85 | ``` 86 | -------------------------------------------------------------------------------- /content/subjects.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Subjects 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | In the past, we saw that each observer got it's own special and unique subscription to an observable. 7 | 8 | Subjects are a little bit different. A subject can broadcast the data to multiple subscribers. (If you've ever played around with EventEmitter in Node, then you might be somewhat familiar with this concept already.) 9 | 10 | When an observer subscribes to a subject, the subject adds it to its internal list of subscribers. When something happens, it notifies everthing that it has on its mailing list. 11 | 12 | You've got four basic varietals of subjects at your disposal: 13 | 14 | - `Subject`: this is basically what we described above. 15 | - `AsyncSubject`: Keeps quiet unit it completes and then it tells everyone subscribed. 16 | - `BehaviorSubject`: Catches new subscribers up with the last value that it emitted. 17 | - `ReplaySubject`: Let's you send a new subscriber the last _n_ values that were emitted. (Basically, this is similar to `BehaviorSubject`, but you're filling in even more of the back story.) 18 | 19 | **Potential Task**: Read up on the Observer Pattern from _Design Patterns_ (e.g. the "Gang of Four" book). 20 | 21 | > Probably a more important distinction between Subject and Observable is that a Subject has state, it keeps a list of observers. On the other hand, an Observable is really just a function that sets up observation. 22 | 23 | —[Ben Lesh](https://benlesh.medium.com/on-the-subject-of-subjects-in-rxjs-2b08b7198b93) 24 | 25 | Technically, a subject can act as both an observable as well as an observer. If you want to take one observable and pipe it out to two different observers, you can toss a subject in the middle to ack as your proxy. The subject acts as the first observables one subscriber, but it allows for multiple subscribers and can pump those messages (e.g. emitted values) out to it's buddies. 26 | 27 | ## Subjects are not reusable 28 | 29 | We saw with observables that they'll tell the same story over and over again every time we hand them a new observer. 30 | 31 | When a subject is done—it's done. That's it. No more. It's complete. It's said all it has to say. 32 | 33 | # Observables and Operators 34 | 35 | **Potential Task**: It might be interesting to build a simplified version of an observable or a subject by hand just so that everyone can see how it works. That said, it could also be a little too in the weeds. Inspiration: https://benlesh.com/posts/learning-observable-by-building-observable/ 36 | 37 | **Potential Task**: Build your own operators by hand. Inspiration: https://benlesh.com/posts/rxjs-operators-in-depth-part-1/ 38 | -------------------------------------------------------------------------------- /shared/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /content/loading-state.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Loading State 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Let's start with something simple to validate that everything works. 7 | 8 | ```js 9 | const loading$ = fromEvent(form, 'submit').pipe(tap(() => showLoading(true))); 10 | ``` 11 | 12 | `fetchData` is a fake API call that lasts about as long as we say it should in that input field. 13 | 14 | ```js 15 | const loading$ = fromEvent(form, 'submit').pipe( 16 | tap(() => showLoading(true)), 17 | exhaustMap(() => fetchData()), 18 | tap(() => showLoading(false)), 19 | ); 20 | ``` 21 | 22 | Now, we're showing the loading field and cleaning up after ourselves when it's done. 23 | 24 | But, we want to delay that loading indicator, right? 25 | 26 | ```js 27 | const showLoading$ = of(true).pipe( 28 | delay(+showLoadingAfterField.value), 29 | tap(() => showLoading(true)), 30 | ); 31 | 32 | const hideLoading$ = of(true).pipe(tap(() => showLoading(false))); 33 | 34 | const loading$ = fromEvent(form, 'submit').pipe( 35 | exhaustMap(() => concat(showLoading$, fetchData(), hideLoading$)), 36 | ); 37 | ``` 38 | 39 | Well, this has some problems, we don't even start fetching the data until after we show the loading indicator. 40 | 41 | We can improve this by racing the data against the start time for the loading indicator. 42 | 43 | ```js 44 | const loading$ = fromEvent(form, 'submit').pipe( 45 | exhaustMap(() => { 46 | const data$ = fetchData(); 47 | const dataOrLoading$ = race(showLoading$, data$); 48 | return concat(dataOrLoading$, data$, hideLoading$); 49 | }), 50 | ); 51 | ``` 52 | 53 | If you look closely at the console, you'll see we fetch the data twice. Once for the race and once to actually get the data. This is because each subscription gets a fresh copy of the observsble. 54 | 55 | There is an operator called `share` that allows us to share once instance between two subscriptions. 56 | 57 | ```js 58 | const loading$ = fromEvent(form, 'submit').pipe( 59 | exhaustMap(() => { 60 | const data$ = fetchData().pipe(share()); 61 | const dataOrLoading$ = race(showLoading$, data$); 62 | return concat(dataOrLoading$, data$, hideLoading$); 63 | }), 64 | ); 65 | ``` 66 | 67 | ## Completed 68 | 69 | ```js 70 | const loading$ = fromEvent(form, 'submit').pipe( 71 | exhaustMap(() => { 72 | const data$ = fetchData().pipe(shareReplay(1)); 73 | 74 | const showLoading$ = of(true).pipe( 75 | delay(+showLoadingAfterField.value), 76 | tap(() => showLoading(true)), 77 | ); 78 | 79 | const hideLoading$ = timer(+showLoadingForAtLeastField.value).pipe(first()); 80 | 81 | const loading$ = concat( 82 | showLoading$, 83 | hideLoading$, 84 | data$.pipe(tap(() => showLoading(false))), 85 | ); 86 | 87 | return race(data$, loading$); 88 | }), 89 | ); 90 | ``` 91 | -------------------------------------------------------------------------------- /applications/merging-timelines/utilities.js: -------------------------------------------------------------------------------- 1 | import { fromEvent, merge, NEVER } from 'rxjs'; 2 | import { 3 | mapTo, 4 | startWith, 5 | switchMap, 6 | tap, 7 | map, 8 | finalize, 9 | } from 'rxjs/operators'; 10 | 11 | import { 12 | addElementToDOM, 13 | emptyElements, 14 | } from '../../utilities/dom-manpulation'; 15 | 16 | export const startButton = document.getElementById('start'); 17 | export const pauseButton = document.getElementById('pause'); 18 | export const clearButton = document.getElementById('clear'); 19 | export const status = document.getElementById('status'); 20 | 21 | const outputs = { 22 | first: document.getElementById('first-output'), 23 | second: document.getElementById('second-output'), 24 | combined: document.getElementById('combined-output'), 25 | }; 26 | 27 | export const setStatus = (isRunning) => { 28 | if (isRunning) { 29 | status.innerText = 'Running…'; 30 | startButton.disabled = true; 31 | pauseButton.disabled = false; 32 | } else { 33 | status.innerText = 'Paused.'; 34 | startButton.disabled = false; 35 | pauseButton.disabled = true; 36 | } 37 | }; 38 | 39 | export const labelWith = (stream) => (value) => ({ stream, value }); 40 | 41 | export const addToOutput = (payload) => { 42 | if (Array.isArray(payload)) { 43 | const results = payload.map(({ metadata }) => metadata.value); 44 | 45 | return addToOutput({ 46 | target: 'combined', 47 | metadata: { 48 | stream: 'combined', 49 | value: JSON.stringify(results), 50 | }, 51 | }); 52 | } 53 | 54 | const { target, metadata } = payload; 55 | const { stream, value } = metadata; 56 | 57 | addElementToDOM(outputs[target], String(value), { 58 | className: `stream-element stream-${stream.toLowerCase()}`, 59 | }); 60 | }; 61 | 62 | const start$ = fromEvent(startButton, 'click').pipe(mapTo(true)); 63 | const pause$ = fromEvent(pauseButton, 'click').pipe(mapTo(false)); 64 | const clear$ = fromEvent(clearButton, 'click'); 65 | 66 | const withMetadata = (target) => (metadata) => { 67 | if (Array.isArray(metadata)) return metadata.map(withMetadata(target)); 68 | return { target, metadata }; 69 | }; 70 | 71 | export const bootstrap = ({ first$, second$, combined$ }) => { 72 | const first = first$.pipe(map(withMetadata('first'))); 73 | const second = second$.pipe(map(withMetadata('second'))); 74 | const combined = combined$.pipe(map(withMetadata('combined'))); 75 | 76 | const run$ = merge(start$, pause$).pipe( 77 | startWith(false), 78 | switchMap((isRunning) => 79 | isRunning 80 | ? merge(first, second, combined).pipe(finalize(() => setStatus(false))) 81 | : NEVER, 82 | ), 83 | tap(addToOutput), 84 | ); 85 | 86 | return run$.subscribe(); 87 | }; 88 | 89 | clear$.subscribe(() => emptyElements(outputs)); 90 | -------------------------------------------------------------------------------- /exercises/creating-observables.test.js: -------------------------------------------------------------------------------- 1 | import { from, of } from 'rxjs'; 2 | 3 | describe('Exercise: Creating Observables', () => { 4 | describe(of, () => { 5 | /** 6 | * Your mission: Create an observable using `of`, subscribe to it, putting 7 | * the values into the `results` array, and get the expectation below to 8 | * pass. 9 | */ 10 | it.skip('should create an observable out of a single value', () => { 11 | const result = []; 12 | 13 | expect(result).toEqual([1]); 14 | }); 15 | 16 | it.skip('should take a series of objects as arguments and create an observable', () => { 17 | const result = []; 18 | 19 | expect(result).toEqual([ 20 | { type: 'INCREMENT', payload: 1 }, 21 | { type: 'RESET' }, 22 | { type: 'INCREMENT', payload: 2 }, 23 | { type: 'DECREMENT', payload: 1 }, 24 | ]); 25 | }); 26 | }); 27 | 28 | describe(from, () => { 29 | it.skip('should take an array of objects as arguments and create an observable', () => { 30 | const result = []; 31 | 32 | expect(result).toEqual([ 33 | { type: 'INCREMENT', payload: 1 }, 34 | { type: 'RESET' }, 35 | { type: 'INCREMENT', payload: 2 }, 36 | { type: 'DECREMENT', payload: 1 }, 37 | ]); 38 | }); 39 | 40 | it.skip('should create an observable from a generator', () => { 41 | function* values() { 42 | yield 1; 43 | yield 2; 44 | yield 3; 45 | return 4; 46 | } 47 | 48 | const result = []; 49 | 50 | expect(result).toEqual([1, 2, 3]); 51 | }); 52 | 53 | /** 54 | * So far, all of our observables have executed synchronously. We can 55 | * create observables from promises, but those will obviously be 56 | * asynchronous in nature. Observables are naturals at this, but Jest 57 | * (or whatever testing framework you prefer) need a little help. 58 | * 59 | * This is a good opportunity for us to learn how to handle the 60 | * completion of an observable differently than the values that are 61 | * emitted from it. 62 | * 63 | * Your mission: collect the values as their emitted, but then 64 | * only assert your expectation once the observable has completed. 65 | */ 66 | it.skip('should create an observable from a promise', (done) => { 67 | const promise = Promise.resolve(1); 68 | const result = []; 69 | 70 | expect(result).toEqual([1]); 71 | done(); 72 | }); 73 | 74 | /** 75 | * We'll get into catching errors in greater detail, but this is a good 76 | * opportunity to see how to respond to an error—in this case, a rejected 77 | * promise—in our observables. 78 | */ 79 | it.skip('should create an observable from a promise that rejects', (done) => { 80 | const promise = Promise.reject({ error: 'Something terrible happened' }); 81 | 82 | expect(error).toEqual({ error: 'Something terrible happened' }); 83 | done(); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /content/basic-counter.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic Counter (Exercise) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Making observables out of what effectively are arrays is all well and good, but what if we wanted to started taking advantage of this whole "values over time" thing that we've been talking about? 7 | 8 | Well, it turns out that we have _even more_ ways to create observables. We can use `interval` in order to produce values at a regular—umm—interval. 9 | 10 | ```js 11 | const { interval } = require('rxjs'); 12 | 13 | const startingTime = Date.now(); 14 | const tick$ = interval(1000); 15 | 16 | tick$.subscribe(() => console.log(Date.now() - startingTime)); 17 | 18 | // Logs: 1002, 2002, 3002, 4003, 5002 19 | ``` 20 | 21 | One thing to notice is that it waits however many milliseconds before it produces a value. 22 | 23 | `timer` is similiar in so far as it produces a value after a given number of milliseconds, but it also can take a second argument where it behaves a lot like `interval`. 24 | 25 | This is how it behaves with one value. 26 | 27 | ```js 28 | const { timer } = require('rxjs'); 29 | 30 | const startingTime = Date.now(); 31 | const tick$ = timer(5000); 32 | 33 | tick$.subscribe(() => console.log(Date.now() - startingTime)); 34 | 35 | // Logs: 5002 36 | ``` 37 | 38 | And, if we give it two arguments. It will produce a value after the initial value and again every _n_ milliseconds. 39 | 40 | ```js 41 | const { timer } = require('rxjs'); 42 | 43 | const startingTime = Date.now(); 44 | const tick$ = timer(2000, 5000); 45 | 46 | tick$.subscribe(() => console.log(Date.now() - startingTime)); 47 | 48 | // Logs: 2003, 7007, 12006 49 | ``` 50 | 51 | ## Cleaning Up an Interval Observable 52 | 53 | We'll explore some additional ways to subscribe and unsubscribe from an observable, but let's start with the basics. When we call `subscribe`, we get back a `Subscription` object. This object has a very useful method called `unsubscribe`. 54 | 55 | We don't know everything we need to know in order to do this purely with RxJS just yet, but let's take a naïve approach for now. 56 | 57 | ```js 58 | const interval$ = interval(1000); 59 | 60 | const subscription = interval$.subscribe(console.log); 61 | 62 | setTimeout(() => subscription.unsubscribe(), 5000); 63 | ``` 64 | 65 | This can be useful in client-side frameworks where you might want to subscribe to an observable when a component mounts, but also then unsubscribe from it when the component unmounts. 66 | 67 | ## Your Mission 68 | 69 | Alright, so we have a few new tricks up our sleeves. 70 | 71 | - We know how to create observables that fire at regular intervals using `timer` and `interval`. 72 | - We know how to create observables from DOM events using `fromEvent` and listen to them. 73 | 74 | Given the _very_ simple UI in `applications/basic-counter`, can you wire up this simple counter. 75 | 76 | Hitting the start button should create an `interval` observable that updates the value of the counter. 77 | 78 | Try it out with `timer` too just to get a feel for the difference. 79 | 80 | What did you learn about the values that `interval` and `timer` emit? 81 | -------------------------------------------------------------------------------- /shared/prism.css: -------------------------------------------------------------------------------- 1 | /* Source: https://github.com/SaraVieira/prism-theme-night-owl */ 2 | 3 | code[class*='language-'], 4 | pre[class*='language-'] { 5 | color: #d6deeb; 6 | font-family: 'Courier Prime', Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', 7 | monospace; 8 | text-align: left; 9 | white-space: pre; 10 | word-spacing: normal; 11 | word-break: normal; 12 | word-wrap: normal; 13 | line-height: 1.5; 14 | 15 | -moz-tab-size: 4; 16 | -o-tab-size: 4; 17 | tab-size: 4; 18 | 19 | -webkit-hyphens: none; 20 | -moz-hyphens: none; 21 | -ms-hyphens: none; 22 | hyphens: none; 23 | } 24 | 25 | pre[class*='language-']::-moz-selection, 26 | pre[class*='language-'] ::-moz-selection, 27 | code[class*='language-']::-moz-selection, 28 | code[class*='language-'] ::-moz-selection { 29 | text-shadow: none; 30 | background: rgba(29, 59, 83, 0.99); 31 | } 32 | 33 | pre[class*='language-']::selection, 34 | pre[class*='language-'] ::selection, 35 | code[class*='language-']::selection, 36 | code[class*='language-'] ::selection { 37 | text-shadow: none; 38 | background: rgba(29, 59, 83, 0.99); 39 | } 40 | 41 | @media print { 42 | code[class*='language-'], 43 | pre[class*='language-'] { 44 | text-shadow: none; 45 | } 46 | } 47 | 48 | /* Code blocks */ 49 | pre[class*='language-'] { 50 | padding: 1em; 51 | margin: 0.5em 0; 52 | overflow: auto; 53 | } 54 | 55 | :not(pre) > code[class*='language-'], 56 | pre[class*='language-'] { 57 | color: white; 58 | background: #011627; 59 | } 60 | 61 | :not(pre) > code[class*='language-'] { 62 | padding: 0.1em; 63 | border-radius: 0.3em; 64 | white-space: normal; 65 | } 66 | 67 | .token.comment, 68 | .token.prolog, 69 | .token.cdata { 70 | color: rgb(99, 119, 119); 71 | font-style: italic; 72 | } 73 | 74 | .token.punctuation { 75 | color: rgb(199, 146, 234); 76 | } 77 | 78 | .namespace { 79 | color: rgb(178, 204, 214); 80 | } 81 | 82 | .token.deleted { 83 | color: rgba(239, 83, 80, 0.56); 84 | font-style: italic; 85 | } 86 | 87 | .token.symbol, 88 | .token.property { 89 | color: rgb(128, 203, 196); 90 | } 91 | 92 | .token.tag, 93 | .token.operator, 94 | .token.keyword { 95 | color: rgb(127, 219, 202); 96 | } 97 | 98 | .token.boolean { 99 | color: rgb(255, 88, 116); 100 | } 101 | 102 | .token.number { 103 | color: rgb(247, 140, 108); 104 | } 105 | 106 | .token.constant, 107 | .token.function, 108 | .token.builtin, 109 | .token.char { 110 | color: rgb(130, 170, 255); 111 | } 112 | 113 | .token.selector, 114 | .token.doctype { 115 | color: rgb(199, 146, 234); 116 | font-style: italic; 117 | } 118 | 119 | .token.attr-name, 120 | .token.inserted { 121 | color: rgb(173, 219, 103); 122 | font-style: italic; 123 | } 124 | 125 | .token.string, 126 | .token.url, 127 | .token.entity, 128 | .language-css .token.string, 129 | .style .token.string { 130 | color: rgb(173, 219, 103); 131 | } 132 | 133 | .token.class-name, 134 | .token.atrule, 135 | .token.attr-value { 136 | color: rgb(255, 203, 139); 137 | } 138 | 139 | .token.regex, 140 | .token.important, 141 | .token.variable { 142 | color: rgb(214, 222, 235); 143 | } 144 | 145 | .token.important, 146 | .token.bold { 147 | font-weight: bold; 148 | } 149 | 150 | .token.italic { 151 | font-style: italic; 152 | } 153 | -------------------------------------------------------------------------------- /api/server.js: -------------------------------------------------------------------------------- 1 | import { createRequire } from 'module'; 2 | import express from 'express'; 3 | import bodyParser from 'body-parser'; 4 | import base64 from 'base-64'; 5 | import cors from 'cors'; 6 | import _ from 'lodash'; 7 | 8 | const { decode, encode } = base64; 9 | const { urlencoded } = bodyParser; 10 | const { shuffle } = _; 11 | 12 | const require = createRequire(import.meta.url); 13 | const app = express(); 14 | 15 | const dogFacts = require('./dog-facts'); 16 | const pokemon = require('./pokemon'); 17 | 18 | const pokemonMetadata = pokemon.map(({ name, classification, id }, index) => ({ 19 | id, 20 | name, 21 | classification, 22 | })); 23 | 24 | app.use(cors()); 25 | app.use( 26 | urlencoded({ 27 | extended: true, 28 | }), 29 | ); 30 | app.use(express.static('public')); 31 | 32 | app.get('/', (request, response) => { 33 | response.redirect('/api/pokemon'); 34 | }); 35 | 36 | const withChaos = (request, response, next) => { 37 | let delay = parseInt(request.query.delay || 0, 10); 38 | let flakiness = parseInt(request.query.flakiness || 0, 10); 39 | let chaos = !!request.query.chaos; 40 | 41 | if (chaos) delay = (delay + 1) * Math.random() + 1000; 42 | 43 | if (flakiness && Date.now() % flakiness === 0) { 44 | response.status(500); 45 | return response.json({ error: 'Something went wrong.' }); 46 | } 47 | 48 | setTimeout(next, delay); 49 | }; 50 | 51 | app.get('/api/pokemon/search', withChaos, (request, response) => { 52 | response.json({ pokemon: [] }); 53 | }); 54 | 55 | app.get('/api/pokemon/search/:query', withChaos, (request, response) => { 56 | let query = request.params.query && request.params.query.toLowerCase(); 57 | 58 | let page = request.query.page ? parseInt(decode(request.query.page)) : 0; 59 | 60 | let limit = +request.query.limit || 10; 61 | 62 | let matching = pokemonMetadata.filter(({ name }) => 63 | name.toLowerCase().startsWith(query), 64 | ); 65 | 66 | let selection = matching.slice(page, page + limit); 67 | 68 | let nextPage = matching[page + limit + 1] 69 | ? encode(page + limit + 1) 70 | : undefined; 71 | 72 | response.json({ 73 | pokemon: selection, 74 | nextPage, 75 | }); 76 | }); 77 | 78 | app.get('/api/pokemon/:id', withChaos, (request, response) => { 79 | const id = parseInt(request.params.id, 10); 80 | response.json(pokemon[id - 1]); 81 | }); 82 | 83 | app.get('/api/pokemon', withChaos, (request, response) => { 84 | let page = request.query.page ? parseInt(decode(request.query.page)) : 0; 85 | let limit = parseInt(request.query.limit, 10) || 10; 86 | 87 | let selection = pokemonMetadata.slice(page, page + limit); 88 | let nextPage = pokemon[page + limit + 1] 89 | ? encode(page + limit + 1) 90 | : undefined; 91 | 92 | const token = nextPage ? encodeURIComponent(nextPage) : null; 93 | 94 | response.json({ 95 | pokemon: selection, 96 | nextPage: token, 97 | }); 98 | }); 99 | 100 | app.get('/api/facts', withChaos, (request, response) => { 101 | let count = parseInt(request.query.count || 3, 10); 102 | response.json({ 103 | facts: shuffle(dogFacts).slice(0, count), 104 | }); 105 | }); 106 | 107 | app.get('/api/facts/:id', withChaos, (request, response) => { 108 | response.json({ 109 | fact: dogFacts[request.params.id], 110 | }); 111 | }); 112 | 113 | const listener = app.listen(process.env.PORT || 3333, () => { 114 | console.log(`Your app is listening on port ${listener.address().port}.`); 115 | }); 116 | -------------------------------------------------------------------------------- /content/creating-observables-solution.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Creating Observables (Solution) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Here are the answers from the back of the textbook in case you get stuck. 7 | 8 | Some things to notice: 9 | 10 | - Promises are asynchronous, so our Jest tests need to account for that. 11 | - If we just want the values that are emitted from an array, then we can simply pass a function. But, if we care about the error or whether or not the observable completed, then we need to pass an object with functions for whatever events we care about. 12 | 13 | ```js 14 | import { from, of } from 'rxjs'; 15 | 16 | describe('Exercise: Creating Observables', () => { 17 | describe(of, () => { 18 | it('should create an observable out of a single value', () => { 19 | const example$ = of(1); 20 | const result = []; 21 | 22 | example$.subscribe((value) => result.push(value)); 23 | 24 | expect(result).toEqual([1]); 25 | }); 26 | 27 | it('should take a series of objects as arguments and create an observable', () => { 28 | const example$ = of( 29 | { type: 'INCREMENT', payload: 1 }, 30 | { type: 'RESET' }, 31 | { type: 'INCREMENT', payload: 2 }, 32 | { type: 'DECREMENT', payload: 1 }, 33 | ); 34 | const result = []; 35 | 36 | example$.subscribe((value) => result.push(value)); 37 | 38 | expect(result).toEqual([ 39 | { type: 'INCREMENT', payload: 1 }, 40 | { type: 'RESET' }, 41 | { type: 'INCREMENT', payload: 2 }, 42 | { type: 'DECREMENT', payload: 1 }, 43 | ]); 44 | }); 45 | }); 46 | 47 | describe(from, () => { 48 | it('should take an array of objects as arguments and create an observable', () => { 49 | const example$ = from([ 50 | { type: 'INCREMENT', payload: 1 }, 51 | { type: 'RESET' }, 52 | { type: 'INCREMENT', payload: 2 }, 53 | { type: 'DECREMENT', payload: 1 }, 54 | ]); 55 | const result = []; 56 | 57 | example$.subscribe((value) => result.push(value)); 58 | 59 | expect(result).toEqual([ 60 | { type: 'INCREMENT', payload: 1 }, 61 | { type: 'RESET' }, 62 | { type: 'INCREMENT', payload: 2 }, 63 | { type: 'DECREMENT', payload: 1 }, 64 | ]); 65 | }); 66 | 67 | it('should create an observable from a generator', () => { 68 | function* values() { 69 | yield 1; 70 | yield 2; 71 | yield 3; 72 | return 4; 73 | } 74 | 75 | const example$ = from(values()); 76 | const result = []; 77 | 78 | example$.subscribe((value) => result.push(value)); 79 | 80 | expect(result).toEqual([1, 2, 3]); 81 | }); 82 | 83 | it('should create an observable from a promise', (done) => { 84 | const example$ = from(Promise.resolve(1)); 85 | const result = []; 86 | 87 | example$.subscribe({ 88 | next: (value) => result.push(value), 89 | complete: () => { 90 | expect(result).toEqual([1]); 91 | done(); 92 | }, 93 | }); 94 | }); 95 | 96 | it('should create an observable from a promise that rejects', (done) => { 97 | const example$ = from( 98 | Promise.reject({ error: 'Something terrible happened' }), 99 | ); 100 | 101 | example$.subscribe({ 102 | error: (error) => { 103 | expect(error).toEqual({ error: 'Something terrible happened' }); 104 | done(); 105 | }, 106 | }); 107 | }); 108 | }); 109 | }); 110 | ``` 111 | -------------------------------------------------------------------------------- /content/progressive-data-enhancement.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Progressive Data Enhancement 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | We've all been there. You get some of the base amount of data from on API but you have to hit _another_ API to get everything you need. This should literally be my job description. 7 | 8 | We only get some simple data about our Pokémon. But, what if we wanted to use the initial data at first and them supplment it later? 9 | 10 | ```js 11 | const search$ = fromEvent(form, 'submit').pipe( 12 | map(() => search.value), 13 | switchMap((searchTerm) => 14 | fromFetch(endpoint + searchTerm + '?delay=5000&chaos=true').pipe( 15 | mergeMap((response) => response.json()), 16 | ), 17 | ), 18 | tap(clearResults), 19 | pluck('pokemon'), 20 | mergeMap(identity), 21 | mergeMap((pokemon) => 22 | fromFetch(endpointFor(pokemon.id)).pipe( 23 | mergeMap((response) => response.json()), 24 | ), 25 | ), 26 | tap(addResult), 27 | ); 28 | ``` 29 | 30 | Doing it this way has some problems: You're basically waiting until you get everything. And this is kind of silly because the reason that we're taking on all of this cognitive overhead is because we want to avoid problems like this. 31 | 32 | What we want: 33 | 34 | - Get me what you have immediately. 35 | - Show it on the page. 36 | - Simultaneously: get the enriched data. 37 | - When you have that, add it to the page. 38 | 39 | So, what would this look like? 40 | 41 | ```js 42 | const getPokemon = (searchTerm) => 43 | fromFetch(endpoint + searchTerm).pipe( 44 | mergeMap((response) => response.json()), 45 | ); 46 | 47 | const getAdditionalData = (pokemon) => 48 | fromFetch(endpointFor(pokemon.id)).pipe( 49 | mergeMap((response) => response.json()), 50 | ); 51 | 52 | const search$ = fromEvent(form, 'submit').pipe( 53 | map(() => search.value), 54 | map((event) => event.target.value), 55 | switchMap((searchTerm) => 56 | getPokemon(searchTerm).pipe( 57 | pluck('pokemon'), 58 | mergeMap(identity), 59 | take(1), 60 | switchMap((pokemon) => { 61 | const pokemon$ = of(pokemon); 62 | 63 | const additionalData$ = getAdditionalData(pokemon).pipe( 64 | map((data) => ({ ...pokemon, data })), 65 | ); 66 | 67 | return merge(pokemon$, additionalData$); 68 | }), 69 | ), 70 | ), 71 | tap(renderPokemon), 72 | ); 73 | ``` 74 | 75 | Okay, so there are some issues here as well. You have an issue where if the user rage clicks, then they end up starting this process over and over. For what? There is no new data. This is silly. 76 | 77 | As we've seen before, `switchMap` only listens to the last inner observable. 78 | 79 | One option: We could switch to `exhaustMap`. This however, will introduce a new issue. What happens if they search for something different? 80 | 81 | We could toss in a `takeUntil` on the search field. So, give up once they change. Another option: `distinctUntilChanged`. 82 | 83 | ```js 84 | const search$ = fromEvent(form, 'submit').pipe( 85 | map(() => search.value), 86 | exhaustMap((searchTerm) => 87 | getPokemon(searchTerm).pipe( 88 | pluck('pokemon'), 89 | mergeMap(identity), 90 | take(1), 91 | switchMap((pokemon) => { 92 | const pokemon$ = of(pokemon); 93 | 94 | const additionalData$ = getAdditionalData(pokemon).pipe( 95 | map((data) => ({ ...pokemon, data })), 96 | ); 97 | 98 | return merge(pokemon$, additionalData$); 99 | }), 100 | ), 101 | ), 102 | tap(renderPokemon), 103 | ); 104 | 105 | search$.subscribe(console.log); 106 | ``` 107 | -------------------------------------------------------------------------------- /content/switch-map.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Switch Map 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | So, in a previous example. I had those nifty "Start" and "Pause" buttons working. How did I do that? Was I subscribing and unsubscribing between observables? 7 | 8 | Nope. (Well, RxJS was under the hood, but I wasn't.) I was using `switchMap` to switch between a stream of values and a stream without values. 9 | 10 | I was doing some crazy stuff with multiple streams back then, but we're rendering one stream now. So, let's go ahead and implement a version of this together and then you'll do a similiar example on your own. 11 | 12 | ## `NEVER` 13 | 14 | First, we need to talk about `NEVER`. 15 | 16 | This is a pre-baked observable that RxJS gives us. It never emits and and it never completes. 17 | 18 | (There is also `EMPTY`, which just immediately completes.) 19 | 20 | ## Wiring Up the Buttons 21 | 22 | We've done this before, but let's try it out again. 23 | 24 | ```js 25 | const start$ = fromEvent(startButton, 'click').pipe(mapTo(true)); 26 | const pause$ = fromEvent(pauseButton, 'click').pipe(mapTo(false)); 27 | const isRunning$ = merge(start$, pause$).pipe(startWith(false)); 28 | 29 | isRunning$.subscribe(setStatus); 30 | ``` 31 | 32 | We now have a boolean that tells us whether or not we should run through our observable or not. Based on the last boolean that comes through the stream, we ant to redirect between our stream and `NEVER`. 33 | 34 | That boolean is not going to be the end result. So, let's do our DOM manipulation with a `tap`. 35 | 36 | ```js 37 | const start$ = fromEvent(startButton, 'click').pipe(mapTo(true)); 38 | const pause$ = fromEvent(pauseButton, 'click').pipe(mapTo(false)); 39 | const isRunning$ = merge(start$, pause$).pipe(startWith(false), tap(setStatus)); 40 | 41 | isRunning$.subscribe(); 42 | ``` 43 | 44 | ## Making Promises 45 | 46 | ```js 47 | const start$ = fromEvent(startButton, 'click').pipe(mapTo(true)); 48 | const pause$ = fromEvent(pauseButton, 'click').pipe(mapTo(false)); 49 | const isRunning$ = merge(start$, pause$).pipe( 50 | startWith(false), 51 | tap(setStatus), 52 | switchMap((isRunning) => { 53 | if (isRunning) { 54 | return interval(1000).pipe(mergeMap(getCharacter)); 55 | } else { 56 | return NEVER; 57 | } 58 | }), 59 | tap(render), 60 | ); 61 | 62 | isRunning$.subscribe(); 63 | ``` 64 | 65 | ### Picking Up Where We Left Off 66 | 67 | What if we refactored our code like this? 68 | 69 | ```js 70 | const characters$ = interval(1000).pipe(mergeMap(getCharacter)); 71 | 72 | const start$ = fromEvent(startButton, 'click').pipe(mapTo(true)); 73 | const pause$ = fromEvent(pauseButton, 'click').pipe(mapTo(false)); 74 | const isRunning$ = merge(start$, pause$).pipe( 75 | startWith(false), 76 | tap(setStatus), 77 | switchMap((isRunning) => (isRunning ? characters$ : NEVER)), 78 | tap(render), 79 | ); 80 | ``` 81 | 82 | We're not making a new observable on the fly. So, it should pick up where it left off right? Well, no. Each subscription is a unique instance of that observable. 83 | 84 | If we want to ensure that all subscriptions share the same obsevable, we need to use the `shareReplay()` operator. 85 | 86 | ```js 87 | const characters$ = interval(1000).pipe(mergeMap(getCharacter), shareReplay(0)); 88 | 89 | const start$ = fromEvent(startButton, 'click').pipe(mapTo(true)); 90 | const pause$ = fromEvent(pauseButton, 'click').pipe(mapTo(false)); 91 | const isRunning$ = merge(start$, pause$).pipe( 92 | startWith(false), 93 | tap(setStatus), 94 | switchMap((isRunning) => (isRunning ? characters$ : NEVER)), 95 | tap(render), 96 | ); 97 | ``` 98 | 99 | We'll explore this a little more later. 100 | -------------------------------------------------------------------------------- /content/manipulating-time-follow-along.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Manipulating Time (Follow Along) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Let's head over to our [playground](/applications/manipulating-time) and—erm—play around for a bit, shall we? 7 | 8 | We're mostly just trying to get familiar with the `delay`, `throttleTime`, and `debounceTime` operators here. So, let's get to experimenting, shall we? 9 | 10 | We'll start by putting a simple delay on the button and seeing what happens. 11 | 12 | ```js 13 | const buttonClicks$ = fromEvent(button, 'click').pipe(delay(2000)); 14 | ``` 15 | 16 | This, of course, creates a fun problem. Even though we're the ones that put that delay on the button, it's tempting to want to click it a bunch of times because we don't think it's working because of that delay. 17 | 18 | You'll also notice that `delay` isn't necessarily spacing them out. If we rage click, they all come in roughly the same time, just two seconds in the future. 19 | 20 | So, what would this look like if we said throttled that button a bit? 21 | 22 | ```js 23 | const buttonClicks$ = fromEvent(button, 'click').pipe( 24 | throttleTime(2000), 25 | delay(2000), 26 | ); 27 | ``` 28 | 29 | So, now we can go to town on that button and we'll see that only one message appears. (If you see more than one, you might have to reload the page to clear out those old event listeners.) 30 | 31 | This effect is more pronounced if we remove the `delay` completely and just go at the button with reckless abandonment. 32 | 33 | ```js 34 | const buttonClicks$ = fromEvent(button, 'click').pipe( 35 | throttleTime(2000), 36 | // delay(2000) 37 | ); 38 | ``` 39 | 40 | You can keep clicking, but a new message will only show up every two seconds. 41 | 42 | As we discussed, `debounceTime` works a bit differently. With `debounceTime` we ignore emitted values until there is a period of silence and then we take the last one and deal with it. 43 | 44 | ```js 45 | const buttonClicks$ = fromEvent(button, 'click').pipe(debounceTime(1000)); 46 | ``` 47 | 48 | I can click that button to my heart's content, but a new notification will only be displayed _after_ I chill out for a second. 49 | 50 | ## Throttling and Debouncing with Other Observables 51 | 52 | If you peeked at the API documentation. You might have also seen that there are just plain ol' `throttle` and `debounce` operators as well. These work a little bit differently. They rely on subscribing to some other observable. 53 | 54 | Instead on relying on a given amount of time, this one will throttle or debounce until the dependant observable emits a value. Let's try something like this. 55 | 56 | ```js 57 | const panicButtonClicks$ = fromEvent(panicButton, 'click'); 58 | const buttonClicks$ = fromEvent(button, 'click').pipe( 59 | debounce(() => panicButtonClicks$), 60 | ); 61 | ``` 62 | 63 | I can keep clicking, but nothing will happen until I click the panic button. `throttle` works in the opposite fashion. The first notification will be displayed, but subsequent ones will _not_ be until the panic button is pressed again. 64 | 65 | ```js 66 | const panicButtonClicks$ = fromEvent(panicButton, 'click'); 67 | const buttonClicks$ = fromEvent(button, 'click').pipe( 68 | throttle(() => panicButtonClicks$), 69 | ); 70 | ``` 71 | 72 | ## Mimicking `debounceTime` and `throttleTime` 73 | 74 | We know the folloing two things to be true: 75 | 76 | - We can debounce or throttle an observable stream based on another observable emitting a value. 77 | - We can create an observable that will emit values at regular intervals. 78 | 79 | So, it stands to reason, that we can recreate the behavior of `throttleTime` and `debounceTime`. 80 | 81 | ```js 82 | const buttonClicks$ = fromEvent(button, 'click').pipe( 83 | throttle(() => interval(2000)), 84 | ); 85 | ``` 86 | -------------------------------------------------------------------------------- /shared/style.scss: -------------------------------------------------------------------------------- 1 | @use './fonts.scss'; 2 | @import './colors.scss'; 3 | 4 | @import url('https://fonts.googleapis.com/css2?family=Averia+Serif+Libre:ital,wght@0,300;0,400;0,700;1,300;1,400;1,700&family=Courier+Prime:ital,wght@0,400;0,700;1,400;1,700&family=Montserrat:wght@500;600;700&family=Press+Start+2P&display=swap'); 5 | 6 | html, 7 | body, 8 | * { 9 | box-sizing: border-box; 10 | } 11 | 12 | body, 13 | main { 14 | font-family: 'Averia', serif; 15 | font-size: 20px; 16 | margin: 0; 17 | position: relative; 18 | } 19 | 20 | h1 { 21 | font-family: 'Press Start 2P', monospace; 22 | background-color: $blue; 23 | background-image: linear-gradient(45deg, $blue, $fuschia); 24 | font-size: 3em; 25 | background-clip: text; 26 | line-height: 1.3em; 27 | text-align: center; 28 | -webkit-text-stroke-width: 1px; 29 | -webkit-text-stroke-color: black; 30 | -webkit-background-clip: text; 31 | -webkit-text-fill-color: transparent; 32 | -moz-background-clip: text; 33 | -moz-text-fill-color: transparent; 34 | } 35 | 36 | h2, 37 | h3, 38 | h4, 39 | h5, 40 | h6, 41 | input, 42 | button, 43 | textarea, 44 | label { 45 | font-family: 'Montserrat', sans-serif; 46 | & > code { 47 | background-color: lighten($blue, 20%); 48 | } 49 | } 50 | 51 | pre { 52 | overflow-y: scroll; 53 | } 54 | 55 | pre { 56 | color: white; 57 | padding: 3px 6px; 58 | font-family: 'Courier Prime', monospace; 59 | } 60 | 61 | code { 62 | font-family: 'Courier Prime', monospace; 63 | } 64 | 65 | a { 66 | color: $teal; 67 | text-decoration: none; 68 | &:hover { 69 | text-decoration: dashed; 70 | color: lighten($teal, 10%); 71 | } 72 | } 73 | 74 | p, 75 | li { 76 | line-height: 1.6em; 77 | } 78 | 79 | p code, 80 | li code { 81 | color: black; 82 | font-size: 0.9em; 83 | padding: 2px 4px; 84 | background-color: lighten($teal, 50%); 85 | } 86 | 87 | button { 88 | color: white; 89 | font-weight: bold; 90 | background-color: $fuschia; 91 | padding: 6px 12px; 92 | border: 4px solid darken($fuschia, 20%); 93 | &:hover { 94 | background-color: lighten($fuschia, 10%); 95 | } 96 | &:active { 97 | background-color: darken($fuschia, 10%); 98 | } 99 | &:disabled { 100 | opacity: 50%; 101 | } 102 | } 103 | 104 | input { 105 | color: $green; 106 | font-weight: bold; 107 | background-color: white; 108 | padding: 6px 12px; 109 | border: 4px solid darken($fuschia, 20%); 110 | } 111 | 112 | textarea { 113 | background: lighten($coral, 20%); 114 | border: 2px solid darken($coral, 10%); 115 | min-height: 200px; 116 | } 117 | 118 | canvas { 119 | border: 4px solid $coral; 120 | } 121 | 122 | a > code { 123 | text-decoration: underline; 124 | } 125 | 126 | a:hover > code { 127 | background-color: lighten($blue, 20%); 128 | } 129 | 130 | .container { 131 | max-width: 800px; 132 | margin: auto; 133 | } 134 | 135 | .controls { 136 | border: 2px dashed $blue; 137 | padding: 12px; 138 | margin: 12px 0; 139 | } 140 | 141 | .columns { 142 | display: flex; 143 | height: 100%; 144 | gap: 2em; 145 | } 146 | 147 | .column { 148 | border: 1px solid lighten($blue, 20%); 149 | width: 100%; 150 | } 151 | 152 | .form-field { 153 | margin-bottom: 1em; 154 | label, 155 | input { 156 | width: 100%; 157 | display: block; 158 | } 159 | label { 160 | font-size: 0.8em; 161 | } 162 | } 163 | 164 | .nav-videos { 165 | float: right; 166 | } 167 | 168 | .nav-videos a { 169 | padding: 3px 8px; 170 | text-decoration: none; 171 | } 172 | 173 | @media (max-width: 1200px) { 174 | .mobile-hidden { 175 | display: none; 176 | } 177 | } 178 | 179 | .hidden { 180 | display: none; 181 | } 182 | 183 | #production-notification { 184 | padding: 5px; 185 | border: 1px solid $fuschia; 186 | background: lighten($fuschia, 30%); 187 | margin: 1em 0; 188 | } 189 | -------------------------------------------------------------------------------- /content/merging-timelines-follow-along.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Merging Timelines (Follow Along) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | We're going to get a bit meta here for a moment and we're going to build the tooling that we're going to use to explore some of the functions for combining observables. 7 | 8 | - We're going to create two observables 9 | - One for the start button. 10 | - One for the clear button. 11 | - We'll merge those two observables into one stream. 12 | - When the user clicks "Start", `true` will be passed into the stream. 13 | - When the user clicks "Clear", `false` will be passed into the stream. 14 | - We'll use a new operator called `startWith` to pass an initial value through the stream. 15 | 16 | It will look something like this: 17 | 18 | ```js 19 | const start$ = fromEvent(startButton, 'click').pipe(mapTo(true)); 20 | const clear$ = fromEvent(clearButton, 'click').pipe(mapTo(false)); 21 | 22 | const isRunning$ = merge(start$, clear$).pipe(startWith(false)); 23 | 24 | isRunning$.subscribe(setStatus); 25 | ``` 26 | 27 | With that in place, let's experiment with some of the operators for merging streams. 28 | 29 | `bootstrap` is an observable that I wrote to help us visualize what's going. It's using concepts from this section and one the after it. We'll build a simplified version of it later, but for now, let's just take it for granted. The utility takes three functions and renders elements to the page. 30 | 31 | Let's make a simplified version of it for now. 32 | 33 | ```js 34 | const first$ = interval(1000).pipe(map(labelWith('First')), take(4)); 35 | const second$ = interval(1000).pipe(map(labelWith('Second')), take(4)); 36 | const combined$ = interval(1000).pipe(map(labelWith('Combined')), take(4)); 37 | 38 | bootstrap({ first$, second$, combined$ }); 39 | ``` 40 | 41 | `combined$` isn't combining much of anything at this point. Let's fix that. 42 | 43 | **Quick tasting note**: The first and second columns are their own instances of each subscription independent of the combined observable. 44 | 45 | ## `merge` 46 | 47 | We got a taste of `merge` above. It will simply combine multiple observables. As each child observable emits, so does the merged observable. 48 | 49 | ```js 50 | const first$ = interval(1000).pipe(map(labelWith('First')), take(4)); 51 | const second$ = interval(1000).pipe(map(labelWith('Second')), take(4)); 52 | const combined$ = merge(first$, second$); 53 | ``` 54 | 55 | We can play around with the times a bit and see that combined basically mirrors each observable. 56 | 57 | ## `concat` 58 | 59 | `concat` plays through each observable it has been given in order. It will work through `first$` and then it will play through `next$`. 60 | 61 | ```js 62 | const first$ = interval(1000).pipe(map(labelWith('First')), take(4)); 63 | const second$ = interval(1000).pipe(map(labelWith('Second')), take(4)); 64 | const combined$ = concat(first$, second$); 65 | ``` 66 | 67 | ## `race` 68 | 69 | `race` takes multiple observables and just goes with whatever one emits a value first and then ignores all of the rest of the, 70 | 71 | In this case, `first$` will emit—umm—first and win the race. 72 | 73 | ```js 74 | const first$ = interval(500).pipe(map(labelWith('First')), take(4)); 75 | const second$ = interval(1000).pipe(map(labelWith('Second')), take(4)); 76 | const combined$ = race(first$, second$); 77 | ``` 78 | 79 | But, you can see if we flip the timers, then we'll get the opposite effect. 80 | 81 | ```js 82 | const first$ = interval(1000).pipe(map(labelWith('First')), take(4)); 83 | const second$ = interval(500).pipe(map(labelWith('Second')), take(4)); 84 | const combined$ = race(first$, second$); 85 | ``` 86 | 87 | ## `forkJoin` 88 | 89 | `forkJoin` ignores all of the values until everything is done and then will get you the last value of each. 90 | 91 | ```js 92 | const first$ = interval(1000).pipe(map(labelWith('First')), take(4)); 93 | const second$ = interval(500).pipe(map(labelWith('Second')), take(4)); 94 | const combined$ = forkJoin(first$, second$); 95 | ``` 96 | 97 | ## Final Code 98 | 99 | ```js 100 | import { fromEvent, merge, interval, concat, race, forkJoin } from 'rxjs'; 101 | import { mapTo, startWith, take, map } from 'rxjs/operators'; 102 | import { labelWith } from './utilities'; 103 | 104 | import { startButton, pauseButton, setStatus, bootstrap } from './utilities'; 105 | 106 | const start$ = fromEvent(startButton, 'click').pipe(mapTo(true)); 107 | const clear$ = fromEvent(pauseButton, 'click').pipe(mapTo(false)); 108 | 109 | const isRunning$ = merge(start$, clear$).pipe(startWith(false)); 110 | 111 | isRunning$.subscribe(setStatus); 112 | 113 | const first$ = interval(1000).pipe(map(labelWith('First')), take(4)); 114 | const second$ = interval(1000).pipe(map(labelWith('Second')), take(4)); 115 | const combined$ = merge(first$, second$); 116 | 117 | bootstrap({ first$, second$, combined$ }); 118 | ``` 119 | -------------------------------------------------------------------------------- /content/basic-operators.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Basic Operators 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | We've made observables from values, arrays, and promises. What about generators? They're iterables in JavaScript, so they should be fair game—and they are. 7 | 8 | But, we run into a little bit of a problem when these generators don't ever end. 9 | 10 | ```js 11 | export function* fibonacci() { 12 | let values = [0, 1]; 13 | 14 | while (true) { 15 | let [current, next] = values; 16 | 17 | yield current; 18 | 19 | values = [next, current + next]; 20 | } 21 | } 22 | ``` 23 | 24 | ## Introducing Operators 25 | 26 | Using `from` here would totally work, but we'd end up locking up the main thread as our observable just worked through the values forever and ever. We _could_ add a condition to the while loop to break it off after a certain number of iterations, but we don't need to. Why? Because we have RxJS! 27 | 28 | - Every observable has a `.pipe` method. 29 | - This method takes one or more functions called _operators_. 30 | - Each operator takes the observable, does something to it, and returns a new observable. 31 | - This is similar to method chaining. 32 | - Or, just using `pipe` in Lodash. 33 | 34 | ### take 35 | 36 | Take a certain number of values from an observable and then stop. 37 | 38 | ```js 39 | const example$ = from(fibonacci()).pipe(take(10)); 40 | 41 | example$.subscribe((val) => console.log(val)); 42 | // Logs: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34 43 | ``` 44 | 45 | ### skip 46 | 47 | Ignore the first however many values and then start listening. 48 | 49 | ```js 50 | const example$ = from([1, 2, 3, 4, 5]).pipe(skip(2)); 51 | 52 | example$.subscribe((val) => console.log(val)); 53 | // Logs: 3, 4, 5 54 | ``` 55 | 56 | ### takeWhile and skipWhile 57 | 58 | `take` and `skip` have siblings that will take a function instead of an integer. 59 | 60 | ```js 61 | const under200$ = from(fibonacci()).pipe(takeWhile((value) => value < 200)); 62 | 63 | under200$.subscribe(console.log); 64 | 65 | // Logs: 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144 66 | ``` 67 | 68 | ```js 69 | const over100$ = from(fibonacci()).pipe( 70 | skipWhile((value) => value < 100), 71 | take(4), 72 | ); 73 | 74 | over100$.subscribe(console.log); 75 | 76 | // Logs: 144, 233, 377, 610 77 | ``` 78 | 79 | ### filter 80 | 81 | This one works just like it does with arrays. 82 | 83 | ```js 84 | const evenNumbers$ = of(1, 2, 3, 4, 5, 6, 7, 8).pipe( 85 | filter((n) => n % 2 === 0), 86 | ); 87 | 88 | evenNumbers$.subscribe((val) => console.log(val)); 89 | // Logs: 2, 4, 6, 8 90 | ``` 91 | 92 | ### map 93 | 94 | So does `map`. 95 | 96 | ```js 97 | const doubledNumbers$ = of(1, 2, 3).pipe(map((n) => n * 2)); 98 | 99 | doubledNumbers$.subscribe(console.log); 100 | // Logs: 2, 4, 6 101 | ``` 102 | 103 | ### mapTo 104 | 105 | `mapTo` is just a simplified version of `map`. 106 | 107 | ```js 108 | const over100$ = from(fibonacci()).pipe( 109 | skipWhile((value) => value < 100), 110 | take(4), 111 | mapTo('HELLO!'), 112 | ); 113 | 114 | over100$.subscribe(console.log); 115 | 116 | // Logs: "HELLO!", "HELLO!", "HELLO!", "HELLO!" 117 | ``` 118 | 119 | ### reduce 120 | 121 | The thing to keep in mind with `reduce` is that it only emits one value: the final value upon completion. If you need each intermediate value, you'll want to use `scan`. 122 | 123 | ```js 124 | const under200$ = from(fibonacci()).pipe( 125 | takeWhile((value) => value < 200), 126 | reduce((total, value) => total + value, 0), 127 | ); 128 | 129 | under200$.subscribe(console.log); 130 | 131 | // Logs: 375 132 | ``` 133 | 134 | ### scan 135 | 136 | `scan` behaves like `reduce`, but it also gives us every intermediate value along the way. 137 | 138 | ```js 139 | const under200$ = from(fibonacci()).pipe( 140 | takeWhile((value) => value < 200), 141 | reduce((total, value) => total + value, 0), 142 | ); 143 | 144 | under200$.subscribe(console.log); 145 | 146 | // Logs: 1, 3, 6, 11, 19, 32, 53, 87, 142, 231, 375 147 | ``` 148 | 149 | We can also combine it with `take` if we wanted to use a certain number of values. 150 | 151 | ```js 152 | const fibonacci$ = range(0, Infinity).pipe( 153 | scan(([curr, next]) => [next, curr + next], [0, 1]), 154 | map(([curr]) => curr), 155 | take(5), 156 | ); 157 | 158 | fibonacci$.subscribe(console.log); 159 | ``` 160 | 161 | ### tap 162 | 163 | One of the problems with `.pipe` is that you you only get the value at the very end. This can be tricky for debugging. `tap` allows you to do something and immediately return the value that you started with. This can be useful for side effects—most notably logging to the console and manipulating the DOM. 164 | 165 | ```js 166 | const div = document.querySelector('div'); 167 | 168 | const example$ = from([1, 2, 3, 4]).pipe( 169 | tap((value) => console.log(`About to set the
to ${value}.`)), 170 | tap((value) => { 171 | div.innerText = value; 172 | }), 173 | tap((value) => console.log(`Set the
to ${value}.`)), 174 | ); 175 | ``` 176 | 177 | ## Your Mission 178 | 179 | In `exercises/basic-operators.test.js`, there are a series of quick exercises. Your job is to make the tests pass. 180 | -------------------------------------------------------------------------------- /content/mapping-follow-along.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Adventures in Mapping (Follow Along) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | Whether it's with arrays or observables, mapping values is a common endeavor. So far in this workshop, we've mapped values into other values, but things can get a little tricky when we want to map values into other observables. 7 | 8 | We end up with nested observables. 9 | 10 | We know that we can turn a promise into an observable pretty easily. 11 | 12 | ```js 13 | const character$ = from(getCharacter(1)).pipe(pluck('name')); 14 | 15 | character$.subscribe(render); 16 | ``` 17 | 18 | But, what if we wanted to get multiple characters? 19 | 20 | ```js 21 | const characters$ = of(1, 2, 3, 4).pipe(map((id) => getCharacter(id))); 22 | 23 | characters$.subscribe(render); 24 | ``` 25 | 26 | Well, that gives me back an array of promises, which I guess I could turn into observables or something? 27 | 28 | ```js 29 | const characters$ = of(1, 2, 3, 4).pipe( 30 | map((id) => getCharacter(id)), 31 | map((promise) => from(promise)), 32 | ); 33 | 34 | characters$.subscribe(render); 35 | ``` 36 | 37 | Ugh. Now I have an observable full of observables. If only I had a way to get them into one stream. Do we know of any way to do that? 38 | 39 | Conceptually `merge` is the right choice. But we need it as an operator and not as a creator. 40 | 41 | ## mergeAll 42 | 43 | What we want to do is take all of the observables in my stream and merge them back into the main timeline. 44 | 45 | ```js 46 | const characters$ = of(1, 2, 3, 4).pipe( 47 | map((id) => getCharacter(id)), 48 | map((promise) => from(promise)), 49 | mergeAll(), 50 | pluck('name'), 51 | ); 52 | ``` 53 | 54 | ## mergeMap 55 | 56 | This is a common enough thing that we actually have a operator that combines mapping and merging into one operator. 57 | 58 | It's called `mergeMap`. 59 | 60 | ```js 61 | const characters$ = of(1, 2, 3, 4).pipe( 62 | map((id) => getCharacter(id)), 63 | mergeMap((promise) => from(promise)), 64 | ); 65 | 66 | characters$.subscribe(render); 67 | ``` 68 | 69 | We could even make this a little shorter by wrapping those promises with `from` in one single operation. 70 | 71 | ```js 72 | const characters$ = of(1, 2, 3, 4).pipe( 73 | mergeMap((id) => from(getCharacter(id))), 74 | ); 75 | 76 | characters$.subscribe(render); 77 | ``` 78 | 79 | We can now go about our business as we did before. 80 | 81 | ```js 82 | const characters$ = of(1, 2, 3, 4).pipe( 83 | mergeMap((id) => from(getCharacter(id))), 84 | pluck('name'), 85 | ); 86 | ``` 87 | 88 | ## Returning Observables 89 | 90 | Let's just play around with this just a little more. 91 | 92 | This doesn't work. 93 | 94 | ```js 95 | const example$ = of('John', 'Paul', 'George', 'Ringo').pipe( 96 | map((value, index) => interval(index * 1000).pipe(take(4))), 97 | ); 98 | 99 | example$.subscribe(render); 100 | ``` 101 | 102 | But this does. 103 | 104 | ```js 105 | const example$ = of(1, 2, 3, 4).pipe( 106 | mergeMap((value, index) => interval(index * 1000).pipe(take(4))), 107 | ); 108 | 109 | example$.subscribe(render); 110 | ``` 111 | 112 | We can even get a little crazier with the mapping. 113 | 114 | ```js 115 | const example$ = of('John', 'Paul', 'George', 'Ringo').pipe( 116 | mergeMap((beatle, index) => 117 | interval(index * 1000).pipe( 118 | take(4), 119 | map((i) => `${beatle} ${i}`), 120 | ), 121 | ), 122 | ); 123 | 124 | example$.subscribe(render); 125 | ``` 126 | 127 | ## concatMap 128 | 129 | This works, just like `concat`. It plays through each one in the order that it was received. 130 | 131 | ```js 132 | const example$ = of('John', 'Paul', 'George', 'Ringo').pipe( 133 | concatMap((beatle, index) => 134 | interval(index * 1000).pipe( 135 | take(4), 136 | map((i) => `${beatle} ${i}`), 137 | ), 138 | ), 139 | ); 140 | 141 | example$.subscribe(render); 142 | ``` 143 | 144 | ## switchMap 145 | 146 | Anytime a new observable comes through the map, `switchMap` unsubscribes from the previous observable and switches to the new one. 147 | 148 | ```js 149 | const example$ = of('John', 'Paul', 'George', 'Ringo').pipe( 150 | switchMap((beatle, index) => 151 | interval(index * 1000).pipe( 152 | take(4), 153 | map((i) => `${beatle} ${i}`), 154 | ), 155 | ), 156 | ); 157 | 158 | example$.subscribe(render); 159 | ``` 160 | 161 | Since the John, Paul, George, and Ringo are shooting through the pipe synchronously, `switchMap` quickly switches between them and ends up only subscribing to Ringo. 162 | 163 | ## exhaustMap 164 | 165 | This is pretty much the opposite. `exhaustMap` grabs onto to the first one and ignores everything else until it is done doing what it's doing. 166 | 167 | ```js 168 | const example$ = of('John', 'Paul', 'George', 'Ringo').pipe( 169 | exhaustMap((beatle, index) => 170 | interval(index * 1000).pipe( 171 | take(4), 172 | map((i) => `${beatle} ${i}`), 173 | ), 174 | ), 175 | ); 176 | 177 | example$.subscribe(render); 178 | ``` 179 | 180 | ### combineLatestAll 181 | 182 | `combineLatestAll` is a bit like `scan`. It will give you an array of the latest value emitted by each of the child observables. The only caveat is that each of them must have emitted once first. 183 | 184 | ```js 185 | const example$ = of('John', 'Paul', 'George', 'Ringo').pipe( 186 | map((beatle, index) => 187 | interval(index * 1000).pipe( 188 | take(4), 189 | map((i) => `${beatle} ${i}`), 190 | ), 191 | ), 192 | combineLatestAll(), 193 | ); 194 | ``` 195 | 196 | You'll notice that we don't hear anything until Ringo, the last to emit, emits a value. 197 | 198 | If needed, we can kick things off with `startWith`. 199 | 200 | ```js 201 | const example$ = of('John', 'Paul', 'George', 'Ringo').pipe( 202 | map((beatle, index) => 203 | interval(index * 1000).pipe( 204 | startWith('(Not Started)'), 205 | take(5), 206 | map((i) => `${beatle} ${i}`), 207 | ), 208 | ), 209 | combineLatestAll(), 210 | ); 211 | 212 | example$.subscribe(render); 213 | ``` 214 | 215 | **Bonus**: We also have `endWith` that we could use. 216 | 217 | ```js 218 | const example$ = of('John', 'Paul', 'George', 'Ringo').pipe( 219 | map((beatle, index) => 220 | interval(index * 1000).pipe( 221 | startWith('(Not Started)'), 222 | take(6), 223 | endWith('(Done)'), 224 | map((i) => `${beatle} ${i}`), 225 | ), 226 | ), 227 | combineLatestAll(), 228 | ); 229 | 230 | example$.subscribe(render); 231 | ``` 232 | 233 | ## Conclusion 234 | 235 | Okay, that's all kind of silly. Let's review some of the operators and then we'll build some more practical things. 236 | -------------------------------------------------------------------------------- /content/fetching-dog-facts.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Fetching from an API 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | First, let's make it work with that button, shall we? Let's create a stream out of clicks on that button, shall we? 7 | 8 | **Nota bene**: If you're struggling to get your local server up and running, the API is also hosted at https://rxjs-api.glitch.me/api/facts. 9 | 10 | ```js 11 | const endpoint = 'http://localhost:3333/api/facts'; 12 | 13 | const fetch$ = fromEvent(fetchButton, 'click').pipe( 14 | mergeMap(() => 15 | fromFetch(endpoint).pipe(mergeMap((response) => response.json())), 16 | ), 17 | ); 18 | ``` 19 | 20 | Great, it works, but this is nothing special. 21 | 22 | ## Dealing with Chaos 23 | 24 | Let's say that there are some "imperfect" network conditions. 25 | 26 | ```js 27 | const endpoint = 'http://localhost:3333/api/facts?delay=3000&chaos=1'; 28 | ``` 29 | 30 | This adds a slight delay and a little bit of randomness to our response times. Go ahead and click on the button a few times. This is mildly annoying in our sample application, but can become a lot worse in a real-world application. 31 | 32 | So, how would we deal with that? 33 | 34 | The answer to that question ultimately depends on how you want to solve for it. Should the last request win or should we finish what we started in before loading more onto the page? 35 | 36 | Since this is just displaying random facts and it's likely—but not guaranteed—that the first request will come back first. 37 | 38 | When we worked on the counter, we used `switchMap` to take the latest event from stream. On the flip side, we can use `exhaustMap` to wait until the first observable has completed. 39 | 40 | Let's take it for a spin. 41 | 42 | ```js 43 | const fetch$ = fromEvent(fetchButton, 'click').pipe( 44 | exhaustMap(() => 45 | fromFetch(endpoint).pipe(mergeMap((response) => response.json())), 46 | ), 47 | ); 48 | ``` 49 | 50 | Now, you can slam on that button as many times as you want and it doesn't matter. 51 | 52 | You _could_ throttle the clicks, but this is probably a terrible idea. You don't care about a certain amount of time. You care whether or not the last request came back. If it came back super fast, then you don't want to ignore subsequent clicks completely, right? 53 | 54 | Here is some code anyway: 55 | 56 | ```js 57 | const fetch$ = fromEvent(fetchButton, 'click').pipe( 58 | throttleTime(1000), 59 | tap(console.log), 60 | exhaustMap(() => 61 | fromFetch(endpoint).pipe(mergeMap((response) => response.json())), 62 | ), 63 | ); 64 | ``` 65 | 66 | ### Dealing with Errors 67 | 68 | What happens if the request fails? 69 | 70 | ### Fetching at an Interval 71 | 72 | So, what if wanted to refresh this data every so often? (Keep in mind, we're keeping chaos mode turned on, here. So, all of the previous issues will remain.) 73 | 74 | Let's start with just a super simple approach that does not involve learning anything new. 75 | 76 | ```js 77 | const fetch$ = fromEvent(fetchButton, 'click').pipe( 78 | tap(clearError), 79 | exhaustMap(() => 80 | fromFetch(endpoint).pipe( 81 | mergeMap((response) => { 82 | if (response.ok) { 83 | return response.json(); 84 | } else { 85 | return of({ error: 'Something went wrong!' }); 86 | } 87 | }), 88 | ), 89 | ), 90 | ); 91 | 92 | fetch$.subscribe(({ facts, error }) => { 93 | if (error) { 94 | return (errorStatus.innerText = error); 95 | } 96 | clearFacts(); 97 | facts.forEach(addFact); 98 | }); 99 | ``` 100 | 101 | This works in the way that error handling works in Node: we ignore the built in error-handling in JavaScript and just create our own abstraction. 102 | 103 | Okay, but like errors can still happen and I should probably teach you what RxJS gives you in order to handle when they do happen. 104 | 105 | Also, what if you want to recover from this error? 106 | 107 | ```js 108 | const fetch$ = fromEvent(fetchButton, 'click').pipe( 109 | tap(() => clearError()), 110 | exhaustMap(() => 111 | fromFetch(endpoint).pipe( 112 | mergeMap((response) => { 113 | if (response.ok) { 114 | return response.json(); 115 | } else { 116 | throw new Error('Something went wrong!'); 117 | } 118 | }), 119 | catchError((error) => { 120 | console.error(error); 121 | return of({ error: 'The stream caught an error. Cool, right?' }); 122 | }), 123 | ), 124 | ), 125 | ); 126 | ``` 127 | 128 | ### Retrying 129 | 130 | Okay, so here is where it gets cool. We can retry a set number of times. So, let's start by breaking out the actual stream of fetching the data from responding to the clicks in our click stream. 131 | 132 | ```js 133 | const fetchData = () => 134 | fromFetch(endpoint).pipe( 135 | mergeMap((response) => { 136 | if (response.ok) { 137 | return response.json(); 138 | } else { 139 | throw new Error('Something went wrong!'); 140 | } 141 | }), 142 | retry(4), 143 | catchError((error) => { 144 | console.error(error); 145 | return of({ error: 'The stream caught an error. Cool, right?' }); 146 | }), 147 | ); 148 | 149 | const fetch$ = fromEvent(fetchButton, 'click').pipe( 150 | tap(() => clearError()), 151 | exhaustMap(fetchData), 152 | ); 153 | ``` 154 | 155 | Okay, nothing new to see here. Let's keep going. 156 | 157 | So, the simplest possible answer is to create a stream that will finish one stream and move on to the next one. 158 | 159 | ```js 160 | concat(response.json(), fetchData()); 161 | ``` 162 | 163 | If we wanted to buy ourselves some time, we can do that too. This is where we harken back to our timer example from before. We know that we can map intervals into other observables. 164 | 165 | ```js 166 | const fetch$ = fromEvent(fetchButton, 'click').pipe( 167 | tap(() => clearError()), 168 | exhaustMap(fetchData), 169 | switchMap((results) => 170 | concat(of(results), interval(5000).pipe(mergeMap(fetchData)), take(1)), 171 | ), 172 | ); 173 | ``` 174 | 175 | So, this is cool. We'll switch over to the latest set of results, but then we'll tack on another request 5 seconds later. 176 | 177 | ## Pausing the Fetching 178 | 179 | We're seen this movie before. 180 | 181 | ```js 182 | const fetch$ = fromEvent(fetchButton, 'click').pipe(mapTo(true)); 183 | const stop$ = fromEvent(stopButton, 'click').pipe(mapTo(false)); 184 | 185 | const factStream$ = merge(fetch$, stop$).pipe( 186 | startWith(false), 187 | switchMap((shouldFetch) => { 188 | return shouldFetch 189 | ? timer(0, 5000).pipe( 190 | tap(() => clearError()), 191 | tap(() => clearFacts()), 192 | exhaustMap(fetchData), 193 | ) 194 | : NEVER; 195 | }), 196 | ); 197 | 198 | factStream$.subscribe(addFacts); 199 | ``` 200 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | RxJS Fundamentals 4 | 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 |

RxJS Fundamentals

20 |

21 | You can find the Github repository for the code and lessons 22 | 23 | here . You can learn more about 25 | getting started in the README.md 27 | . 29 |

30 |

31 | Watch the Rx.js fundamentals course here. 32 |

33 |
34 |
35 | 135 |
136 |

Applications

137 | 138 |
    139 |
  1. 140 | 141 | Observable Playground 142 | 143 |
  2. 144 |
  3. 145 | 146 | Generating Observables from Events 147 | 148 |
  4. 149 |
  5. 150 | Basic Counter 151 |
  6. 152 |
  7. 153 | 154 | Manipulating Time 155 | 156 |
  8. 157 |
  9. 158 | 159 | Markdown Renderer 160 | 161 | (Completed) 164 |
  10. 165 |
  11. 166 | 167 | Merging Timelines 168 | 169 |
  12. 170 |
  13. 171 | Mapping Exercises 172 | (Completed) 174 |
  14. 175 |
  15. 176 | Dog Facts (Completed) 180 |
  16. 181 |
  17. 182 | 183 | Loading States 184 | 185 |
  18. 186 |
  19. 187 | Pokémon API 188 | (Paginated,  190 | Autocomplete,  192 | Data Enhancement) 195 |
  20. 196 |
  21. 197 | Pixel Editor 198 |
  22. 199 |
200 |
201 |
202 |
203 | 213 | 214 | 215 | -------------------------------------------------------------------------------- /content/_counter-follow-along.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Counter (Follow Along) 3 | layout: layouts/lesson.njk 4 | --- 5 | 6 | If the purpose of using RxJS is to play around with time, then it makes sense to kick the tires on it by building a simple timer, right? Well, I think so anyway. 7 | 8 | We're start by selecting all of the elements on the page. 9 | 10 | ```js 11 | const count = document.getElementById('count'); 12 | const start = document.getElementById('start'); 13 | const pause = document.getElementById('pause'); 14 | const setTo = document.getElementById('set'); 15 | const setToAmount = document.getElementById('set-amount'); 16 | const reset = document.getElementById('reset'); 17 | const countUp = document.getElementById('count-up'); 18 | const countDown = document.getElementById('count-down'); 19 | ``` 20 | 21 | Okay, let's start with the most naive version of any of this. 22 | 23 | ```js 24 | interval(1000).subscribe((value) => (count.value = value)); 25 | ``` 26 | 27 | This will iterate our value and I guess it's technically a counter. But you can't start it, stop it or reset it. But, it's using RxJS and it's a start—so, we'll take it. 28 | 29 | Okay, so what if we wanted to at least get the start button working? 30 | 31 | Well, what we can do here is we can start our interval stream, but basically just ignore it until we get literally anything emitted from a stream attached to the start button. 32 | 33 | ```js 34 | const start$ = fromEvent(start, 'click'); 35 | 36 | const counter$ = interval(1000).pipe(skipUntil(start$)); 37 | 38 | counter$.subscribe((value) => (count.value = value)); 39 | ``` 40 | 41 | This implementation _also_ has a problem: the `interval` stream is ticking upwards, with a new integer every second, but we're just ignoring them. This means that when we finally do start our timer, it's going to pick up from whatever tick the `interval` stream was on. 42 | 43 | Rather than rely on whatever interval thinks the current count is, we can just take the values that make it through our stream and count those. 44 | 45 | **Quick Exercise**: Can you have the stream go back to ignoring values once the _pause_ button has been pressed? 46 | 47 | Here is a possible solution. 48 | 49 | ```js 50 | const start$ = fromEvent(start, 'click'); 51 | const pause$ = fromEvent(pause, 'click'); 52 | 53 | const counter$ = interval(1000).pipe( 54 | skipUntil(start$), 55 | scan((total) => total + 1, 0), 56 | takeUntil(pause$), 57 | ); 58 | ``` 59 | 60 | Okay, so this works for a single use timer. But we basically only start it once and we can only pause it once. 61 | 62 | For an extra fun bonus, if you pause the counter before you start it, you'll ever turn on because you already told it to no longer listen to values emitted from the interval. 63 | 64 | ## Adding State 65 | 66 | So, what we could do is something similiar to what might do in React. We could hold onto an object that stores the current value and whether or not we're actively iterating. 67 | 68 | There are a bunch of ways we could tackle this. Let's try something simple. 69 | 70 | The wrong answer is always be tossing variables in the global scope to store state. 71 | 72 | ```ts 73 | type CounterState = { 74 | value: number; 75 | isActive: boolean; 76 | }; 77 | ``` 78 | 79 | Before we actually solve the issue we had earlier, let's get it working with this new concept of state. 80 | 81 | Let's start with mapping our events to payloads of data that we can use in our stream. 82 | 83 | ```js 84 | const start$ = fromEvent(start, 'click').pipe(mapTo({ isActive: true })); 85 | const pause$ = fromEvent(pause, 'click').pipe(mapTo({ isActive: false })); 86 | ``` 87 | 88 | I'll just say this now: If this is feeling a little bit like Redux to you, then you're probably not totally off the mark. And, if you're lucky, we'll tie this into Redux later as well. 89 | 90 | ## Merging Multiple Streams 91 | 92 | When we used `takeUntil` and `skipUntil` we subscribed to to `start$` and `pause$`, but we weren't exactly using their values anywhere. But, now we want to bring their mapped values into the stream as well. 93 | 94 | This means we're going to need to _merge_ them in with ticks coming from the `interval(1000)` stream. 95 | 96 | We'll deal with the interval again in a moment, but let's merge together the events coming from our two buttons. 97 | 98 | ```js 99 | const counter$ = merge(start$, pause$).pipe( 100 | startWith({ value: 0, isActive: false }), 101 | scan((state, payload) => ({ ...state, ...payload }), {}), 102 | tap(console.log), // Allows for side effects 103 | ); 104 | ``` 105 | 106 | If you press the two buttons, you'll see that we can set the counter is active and inactive states, even if we're removed the actually incrementing of the number for now. 107 | 108 | ## Making a Quick Reducer 109 | 110 | This isn't going to be our final answer for this but it does present a fairly interesting pattern for us. Could we do something similar to what we might do in Redux. 111 | 112 | Hear me out: 113 | 114 | - What if we mapped the start and pause buttons to actions? 115 | - What if interval also was mapped to an action? 116 | - In `scan` could we update the state based on the action? 117 | 118 | Let's try it out. 119 | 120 | The first and somewhat obvious thing to do is to update the values emitted from our buttons. 121 | 122 | ```js 123 | const start$ = fromEvent(start, 'click').pipe(mapTo({ type: 'START' })); 124 | const pause$ = fromEvent(pause, 'click').pipe(mapTo({ type: 'PAUSE' })); 125 | ``` 126 | 127 | We could then do something like this: 128 | 129 | ```js 130 | const start$ = fromEvent(start, 'click').pipe(mapTo({ type: 'START' })); 131 | const pause$ = fromEvent(pause, 'click').pipe(mapTo({ type: 'PAUSE' })); 132 | 133 | const counter$ = merge(start$, pause$).pipe( 134 | scan( 135 | (state, action) => { 136 | if (action.type === 'START') return { ...state, isActive: true }; 137 | if (action.type === 'PAUSE') return { ...state, isActive: false }; 138 | }, 139 | { value: 0, isActive: false }, 140 | ), 141 | tap(console.log), 142 | ); 143 | ``` 144 | 145 | **Quick Exercise**: Okay, can you map the interval to an action and update the state accordingly? 146 | 147 | Solution: 148 | 149 | ```js 150 | const start$ = fromEvent(start, 'click').pipe(mapTo({ type: 'START' })); 151 | const pause$ = fromEvent(pause, 'click').pipe(mapTo({ type: 'PAUSE' })); 152 | const interval$ = interval(1000).pipe(mapTo({ type: 'INCREMENT' })); 153 | 154 | const counter$ = merge(interval$, start$, pause$).pipe( 155 | scan( 156 | (state, action) => { 157 | if (action.type === 'START') return { ...state, isActive: true }; 158 | if (action.type === 'PAUSE') return { ...state, isActive: false }; 159 | if (action.type === 'INCREMENT' && state.isActive) { 160 | return { ...state, value: state.value + 1 }; 161 | } 162 | return state; 163 | }, 164 | { value: 0, isActive: false }, 165 | ), 166 | tap(console.log), 167 | ); 168 | ``` 169 | 170 | There are a bunch of things that I don't like about this solution. I don't like that we're ticking all of the time, regardless of whether or not we even want to update the counter. This feels wasteful and probably won't scale in a more complex example. 171 | 172 | We had perfectly good ticking going on previously. 173 | 174 | Okay, so what about this: What if we did a little bit of stream inception. 175 | 176 | - Merge together a stream of events coming from the start and pause buttons. 177 | - Switch between a stream that emits a new value at every interval and one that just never emits anything ever. 178 | 179 | ## Solution 180 | 181 | ```js 182 | const counter$ = merge(start$, pause$).pipe( 183 | scan( 184 | (state, action) => { 185 | if (action.type === 'START') return { ...state, isActive: true }; 186 | if (action.type === 'PAUSE') return { ...state, isActive: false }; 187 | return state; 188 | }, 189 | { value: 0, isActive: false }, 190 | ), 191 | switchMap((state) => { 192 | if (state.isActive) { 193 | return interval(1000).pipe( 194 | tap(() => { 195 | count.value = ++state.value; 196 | }), 197 | ); 198 | } 199 | return NEVER; 200 | }), 201 | ); 202 | ``` 203 | 204 | ## Exercise 205 | 206 | Can you get the following additional features working? 207 | 208 | - Reset the count 209 | - Set the count to a particular value 210 | - Update the stream to count up 211 | - Update the stream to count down 212 | - Extension: Implement the ability to set it to a particular value 213 | 214 | ## Solution 215 | 216 | ```js 217 | const counter$ = merge(start$, pause$, reset$, countUp$, countDown$).pipe( 218 | scan( 219 | (state, action) => { 220 | if (action.type === 'START') return { ...state, isActive: true }; 221 | if (action.type === 'PAUSE') return { ...state, isActive: false }; 222 | if (action.type === 'RESET') return { ...state, value: 0 }; 223 | if (action.type === 'COUNTUP') return { ...state, increment: 1 }; 224 | if (action.type === 'COUNTDOWN') return { ...state, increment: -1 }; 225 | return state; 226 | }, 227 | { value: 0, isActive: false, increment: 1 }, 228 | ), 229 | tap((state) => (count.value = state.value)), // For the setting and resetting. 230 | switchMap((state) => { 231 | if (state.isActive) { 232 | return interval(1000).pipe( 233 | tap(() => (state.value += state.increment)), 234 | tap(() => (count.value = state.value)), 235 | ); 236 | } 237 | return NEVER; 238 | }), 239 | ); 240 | ``` 241 | 242 | ## Implement set to a given amount 243 | 244 | `addEvent` takes an additional argument, which is basically a function that maps the value. 245 | 246 | ```js 247 | const setValue$ = fromEvent(setTo, 'click', () => 248 | parseInt(setToAmount.value, 10), 249 | ).pipe(map((amount) => ({ type: 'SET', payload: amount }))); 250 | ``` 251 | 252 | Then, in our reducer: 253 | 254 | ```js 255 | if (action.type === 'SET') return { ...state, value: action.payload }; 256 | ``` 257 | 258 | ## Bonus: Breaking It Apart 259 | 260 | ```js 261 | const counterState$ = merge( 262 | start$, 263 | pause$, 264 | reset$, 265 | countUp$, 266 | countDown$, 267 | setValue$, 268 | ).pipe( 269 | scan( 270 | (state, action) => { 271 | if (action.type === 'START') return { ...state, isActive: true }; 272 | if (action.type === 'PAUSE') return { ...state, isActive: false }; 273 | if (action.type === 'RESET') return { ...state, value: 0 }; 274 | if (action.type === 'COUNTUP') return { ...state, increment: 1 }; 275 | if (action.type === 'COUNTDOWN') return { ...state, increment: -1 }; 276 | if (action.type === 'SET') return { ...state, value: action.payload }; 277 | return state; 278 | }, 279 | { value: 0, isActive: false, increment: 1 }, 280 | ), 281 | ); 282 | 283 | const counter$ = merge( 284 | counterState$, 285 | counterState$.pipe( 286 | switchMap((state) => { 287 | if (state.isActive) { 288 | return interval(1000).pipe( 289 | tap(() => (state.value += state.increment)), 290 | mapTo(state), 291 | ); 292 | } 293 | return NEVER; 294 | }), 295 | ), 296 | ); 297 | 298 | counter$.subscribe((state) => (count.value = state.value)); 299 | ``` 300 | 301 | This will have a subtle bug because you're technically switching off of that first one in the `switchMap` which makes RxJS, so adding one final `share` will keep it around. 302 | 303 | ```js 304 | const counterState$ = merge( 305 | start$, 306 | pause$, 307 | reset$, 308 | countUp$, 309 | countDown$, 310 | setValue$, 311 | ).pipe( 312 | scan( 313 | (state, action) => { 314 | // … 315 | return state; 316 | }, 317 | { value: 0, isActive: false, increment: 1 }, 318 | ), 319 | share(), // 👀 320 | ); 321 | ``` 322 | 323 | ### Extra, Extra Bonus 324 | 325 | If you wanted to hide the details and only have the value exposed, you can use `pluck` to grab the value and then `distinctUntilChanged` to basically not emit anything unless that value updates. 326 | 327 | ```js 328 | const counter$ = merge( 329 | counterState$, 330 | counterState$.pipe( 331 | switchMap((state) => { 332 | if (state.isActive) { 333 | return interval(1000).pipe( 334 | tap(() => (state.value += state.increment)), 335 | mapTo(state), 336 | ); 337 | } 338 | return NEVER; 339 | }), 340 | ), 341 | ).pipe(pluck('value'), distinctUntilChanged()); 342 | ``` 343 | -------------------------------------------------------------------------------- /applications/mapping/star-wars-characters.json: -------------------------------------------------------------------------------- 1 | { 2 | "characters": [ 3 | { 4 | "id": 0, 5 | "name": "Luke Skywalker", 6 | "gender": "male", 7 | "skinColor": "fair", 8 | "hairColor": "blond", 9 | "height": 172, 10 | "eyeColor": "blue", 11 | "mass": 77, 12 | "birthYear": "19BBY" 13 | }, 14 | { 15 | "id": 1, 16 | "name": "C-3PO", 17 | "gender": "n/a", 18 | "skinColor": "gold", 19 | "hairColor": "n/a", 20 | "height": 167, 21 | "eyeColor": "yellow", 22 | "mass": 75, 23 | "birthYear": "112BBY" 24 | }, 25 | { 26 | "id": 2, 27 | "name": "R2-D2", 28 | "gender": "n/a", 29 | "skinColor": "white, blue", 30 | "hairColor": "n/a", 31 | "height": 96, 32 | "eyeColor": "red", 33 | "mass": 32, 34 | "birthYear": "33BBY" 35 | }, 36 | { 37 | "id": 3, 38 | "name": "Darth Vader", 39 | "gender": "male", 40 | "skinColor": "white", 41 | "hairColor": "none", 42 | "height": 202, 43 | "eyeColor": "yellow", 44 | "mass": 136, 45 | "birthYear": "41.9BBY" 46 | }, 47 | { 48 | "id": 4, 49 | "name": "Leia Organa", 50 | "gender": "female", 51 | "skinColor": "light", 52 | "hairColor": "brown", 53 | "height": 150, 54 | "eyeColor": "brown", 55 | "mass": 49, 56 | "birthYear": "19BBY" 57 | }, 58 | { 59 | "id": 5, 60 | "name": "Owen Lars", 61 | "gender": "male", 62 | "skinColor": "light", 63 | "hairColor": "brown, grey", 64 | "height": 178, 65 | "eyeColor": "blue", 66 | "mass": 120, 67 | "birthYear": "52BBY" 68 | }, 69 | { 70 | "id": 6, 71 | "name": "Beru Whitesun lars", 72 | "gender": "female", 73 | "skinColor": "light", 74 | "hairColor": "brown", 75 | "height": 165, 76 | "eyeColor": "blue", 77 | "mass": 75, 78 | "birthYear": "47BBY" 79 | }, 80 | { 81 | "id": 7, 82 | "name": "R5-D4", 83 | "gender": "n/a", 84 | "skinColor": "white, red", 85 | "hairColor": "n/a", 86 | "height": 97, 87 | "eyeColor": "red", 88 | "mass": 32, 89 | "birthYear": null 90 | }, 91 | { 92 | "id": 8, 93 | "name": "Biggs Darklighter", 94 | "gender": "male", 95 | "skinColor": "light", 96 | "hairColor": "black", 97 | "height": 183, 98 | "eyeColor": "brown", 99 | "mass": 84, 100 | "birthYear": "24BBY" 101 | }, 102 | { 103 | "id": 9, 104 | "name": "Obi-Wan Kenobi", 105 | "gender": "male", 106 | "skinColor": "fair", 107 | "hairColor": "auburn, white", 108 | "height": 182, 109 | "eyeColor": "blue-gray", 110 | "mass": 77, 111 | "birthYear": "57BBY" 112 | }, 113 | { 114 | "id": 10, 115 | "name": "Anakin Skywalker", 116 | "gender": "male", 117 | "skinColor": "fair", 118 | "hairColor": "blond", 119 | "height": 188, 120 | "eyeColor": "blue", 121 | "mass": 84, 122 | "birthYear": "41.9BBY" 123 | }, 124 | { 125 | "id": 11, 126 | "name": "Wilhuff Tarkin", 127 | "gender": "male", 128 | "skinColor": "fair", 129 | "hairColor": "auburn, grey", 130 | "height": 180, 131 | "eyeColor": "blue", 132 | "mass": null, 133 | "birthYear": "64BBY" 134 | }, 135 | { 136 | "id": 12, 137 | "name": "Chewbacca", 138 | "gender": "male", 139 | "skinColor": null, 140 | "hairColor": "brown", 141 | "height": 228, 142 | "eyeColor": "blue", 143 | "mass": 112, 144 | "birthYear": "200BBY" 145 | }, 146 | { 147 | "id": 13, 148 | "name": "Han Solo", 149 | "gender": "male", 150 | "skinColor": "fair", 151 | "hairColor": "brown", 152 | "height": 180, 153 | "eyeColor": "brown", 154 | "mass": 80, 155 | "birthYear": "29BBY" 156 | }, 157 | { 158 | "id": 14, 159 | "name": "Greedo", 160 | "gender": "male", 161 | "skinColor": "green", 162 | "hairColor": "n/a", 163 | "height": 173, 164 | "eyeColor": "black", 165 | "mass": 74, 166 | "birthYear": "44BBY" 167 | }, 168 | { 169 | "id": 15, 170 | "name": "Jabba Desilijic Tiure", 171 | "gender": "hermaphrodite", 172 | "skinColor": "green-tan, brown", 173 | "hairColor": "n/a", 174 | "height": 175, 175 | "eyeColor": "orange", 176 | "mass": "1,358", 177 | "birthYear": "600BBY" 178 | }, 179 | { 180 | "id": 16, 181 | "name": "Wedge Antilles", 182 | "gender": "male", 183 | "skinColor": "fair", 184 | "hairColor": "brown", 185 | "height": 170, 186 | "eyeColor": "hazel", 187 | "mass": 77, 188 | "birthYear": "21BBY" 189 | }, 190 | { 191 | "id": 17, 192 | "name": "Jek Tono Porkins", 193 | "gender": "male", 194 | "skinColor": "fair", 195 | "hairColor": "brown", 196 | "height": 180, 197 | "eyeColor": "blue", 198 | "mass": 110, 199 | "birthYear": null 200 | }, 201 | { 202 | "id": 18, 203 | "name": "Yoda", 204 | "gender": "male", 205 | "skinColor": "green", 206 | "hairColor": "white", 207 | "height": 66, 208 | "eyeColor": "brown", 209 | "mass": 17, 210 | "birthYear": "896BBY" 211 | }, 212 | { 213 | "id": 19, 214 | "name": "Palpatine", 215 | "gender": "male", 216 | "skinColor": "pale", 217 | "hairColor": "grey", 218 | "height": 170, 219 | "eyeColor": "yellow", 220 | "mass": 75, 221 | "birthYear": "82BBY" 222 | }, 223 | { 224 | "id": 20, 225 | "name": "Boba Fett", 226 | "gender": "male", 227 | "skinColor": "fair", 228 | "hairColor": "black", 229 | "height": 183, 230 | "eyeColor": "brown", 231 | "mass": 78.2, 232 | "birthYear": "31.5BBY" 233 | }, 234 | { 235 | "id": 21, 236 | "name": "IG-88", 237 | "gender": "none", 238 | "skinColor": "metal", 239 | "hairColor": "none", 240 | "height": 200, 241 | "eyeColor": "red", 242 | "mass": 140, 243 | "birthYear": "15BBY" 244 | }, 245 | { 246 | "id": 22, 247 | "name": "Bossk", 248 | "gender": "male", 249 | "skinColor": "green", 250 | "hairColor": "none", 251 | "height": 190, 252 | "eyeColor": "red", 253 | "mass": 113, 254 | "birthYear": "53BBY" 255 | }, 256 | { 257 | "id": 23, 258 | "name": "Lando Calrissian", 259 | "gender": "male", 260 | "skinColor": "dark", 261 | "hairColor": "black", 262 | "height": 177, 263 | "eyeColor": "brown", 264 | "mass": 79, 265 | "birthYear": "31BBY" 266 | }, 267 | { 268 | "id": 24, 269 | "name": "Lobot", 270 | "gender": "male", 271 | "skinColor": "light", 272 | "hairColor": "none", 273 | "height": 175, 274 | "eyeColor": "blue", 275 | "mass": 79, 276 | "birthYear": "37BBY" 277 | }, 278 | { 279 | "id": 25, 280 | "name": "Ackbar", 281 | "gender": "male", 282 | "skinColor": "brown mottle", 283 | "hairColor": "none", 284 | "height": 180, 285 | "eyeColor": "orange", 286 | "mass": 83, 287 | "birthYear": "41BBY" 288 | }, 289 | { 290 | "id": 26, 291 | "name": "Mon Mothma", 292 | "gender": "female", 293 | "skinColor": "fair", 294 | "hairColor": "auburn", 295 | "height": 150, 296 | "eyeColor": "blue", 297 | "mass": null, 298 | "birthYear": "48BBY" 299 | }, 300 | { 301 | "id": 27, 302 | "name": "Arvel Crynyd", 303 | "gender": "male", 304 | "skinColor": "fair", 305 | "hairColor": "brown", 306 | "height": null, 307 | "eyeColor": "brown", 308 | "mass": null, 309 | "birthYear": null 310 | }, 311 | { 312 | "id": 28, 313 | "name": "Wicket Systri Warrick", 314 | "gender": "male", 315 | "skinColor": "brown", 316 | "hairColor": "brown", 317 | "height": 88, 318 | "eyeColor": "brown", 319 | "mass": 20, 320 | "birthYear": "8BBY" 321 | }, 322 | { 323 | "id": 29, 324 | "name": "Nien Nunb", 325 | "gender": "male", 326 | "skinColor": "grey", 327 | "hairColor": "none", 328 | "height": 160, 329 | "eyeColor": "black", 330 | "mass": 68, 331 | "birthYear": null 332 | }, 333 | { 334 | "id": 30, 335 | "name": "Qui-Gon Jinn", 336 | "gender": "male", 337 | "skinColor": "fair", 338 | "hairColor": "brown", 339 | "height": 193, 340 | "eyeColor": "blue", 341 | "mass": 89, 342 | "birthYear": "92BBY" 343 | }, 344 | { 345 | "id": 31, 346 | "name": "Nute Gunray", 347 | "gender": "male", 348 | "skinColor": "mottled green", 349 | "hairColor": "none", 350 | "height": 191, 351 | "eyeColor": "red", 352 | "mass": 90, 353 | "birthYear": null 354 | }, 355 | { 356 | "id": 32, 357 | "name": "Finis Valorum", 358 | "gender": "male", 359 | "skinColor": "fair", 360 | "hairColor": "blond", 361 | "height": 170, 362 | "eyeColor": "blue", 363 | "mass": null, 364 | "birthYear": "91BBY" 365 | }, 366 | { 367 | "id": 33, 368 | "name": "Padmé Amidala", 369 | "gender": "female", 370 | "skinColor": "light", 371 | "hairColor": "brown", 372 | "height": 185, 373 | "eyeColor": "brown", 374 | "mass": 45, 375 | "birthYear": "46BBY" 376 | }, 377 | { 378 | "id": 34, 379 | "name": "Jar Jar Binks", 380 | "gender": "male", 381 | "skinColor": "orange", 382 | "hairColor": "none", 383 | "height": 196, 384 | "eyeColor": "orange", 385 | "mass": 66, 386 | "birthYear": "52BBY" 387 | }, 388 | { 389 | "id": 35, 390 | "name": "Roos Tarpals", 391 | "gender": "male", 392 | "skinColor": "grey", 393 | "hairColor": "none", 394 | "height": 224, 395 | "eyeColor": "orange", 396 | "mass": 82, 397 | "birthYear": null 398 | }, 399 | { 400 | "id": 36, 401 | "name": "Rugor Nass", 402 | "gender": "male", 403 | "skinColor": "green", 404 | "hairColor": "none", 405 | "height": 206, 406 | "eyeColor": "orange", 407 | "mass": null, 408 | "birthYear": null 409 | }, 410 | { 411 | "id": 37, 412 | "name": "Ric Olié", 413 | "gender": "male", 414 | "skinColor": "fair", 415 | "hairColor": "brown", 416 | "height": 183, 417 | "eyeColor": "blue", 418 | "mass": null, 419 | "birthYear": null 420 | }, 421 | { 422 | "id": 38, 423 | "name": "Watto", 424 | "gender": "male", 425 | "skinColor": "blue, grey", 426 | "hairColor": "black", 427 | "height": 137, 428 | "eyeColor": "yellow", 429 | "mass": null, 430 | "birthYear": null 431 | }, 432 | { 433 | "id": 39, 434 | "name": "Sebulba", 435 | "gender": "male", 436 | "skinColor": "grey, red", 437 | "hairColor": "none", 438 | "height": 112, 439 | "eyeColor": "orange", 440 | "mass": 40, 441 | "birthYear": null 442 | }, 443 | { 444 | "id": 40, 445 | "name": "Quarsh Panaka", 446 | "gender": "male", 447 | "skinColor": "dark", 448 | "hairColor": "black", 449 | "height": 183, 450 | "eyeColor": "brown", 451 | "mass": null, 452 | "birthYear": "62BBY" 453 | }, 454 | { 455 | "id": 41, 456 | "name": "Shmi Skywalker", 457 | "gender": "female", 458 | "skinColor": "fair", 459 | "hairColor": "black", 460 | "height": 163, 461 | "eyeColor": "brown", 462 | "mass": null, 463 | "birthYear": "72BBY" 464 | }, 465 | { 466 | "id": 42, 467 | "name": "Darth Maul", 468 | "gender": "male", 469 | "skinColor": "red", 470 | "hairColor": "none", 471 | "height": 175, 472 | "eyeColor": "yellow", 473 | "mass": 80, 474 | "birthYear": "54BBY" 475 | }, 476 | { 477 | "id": 43, 478 | "name": "Bib Fortuna", 479 | "gender": "male", 480 | "skinColor": "pale", 481 | "hairColor": "none", 482 | "height": 180, 483 | "eyeColor": "pink", 484 | "mass": null, 485 | "birthYear": null 486 | }, 487 | { 488 | "id": 44, 489 | "name": "Ayla Secura", 490 | "gender": "female", 491 | "skinColor": "blue", 492 | "hairColor": "none", 493 | "height": 178, 494 | "eyeColor": "hazel", 495 | "mass": 55, 496 | "birthYear": "48BBY" 497 | }, 498 | { 499 | "id": 45, 500 | "name": "Ratts Tyerel", 501 | "gender": "male", 502 | "skinColor": "grey, blue", 503 | "hairColor": "none", 504 | "height": 79, 505 | "eyeColor": null, 506 | "mass": 15, 507 | "birthYear": null 508 | }, 509 | { 510 | "id": 46, 511 | "name": "Dud Bolt", 512 | "gender": "male", 513 | "skinColor": "blue, grey", 514 | "hairColor": "none", 515 | "height": 94, 516 | "eyeColor": "yellow", 517 | "mass": 45, 518 | "birthYear": null 519 | }, 520 | { 521 | "id": 47, 522 | "name": "Gasgano", 523 | "gender": "male", 524 | "skinColor": "white, blue", 525 | "hairColor": "none", 526 | "height": 122, 527 | "eyeColor": "black", 528 | "mass": null, 529 | "birthYear": null 530 | }, 531 | { 532 | "id": 48, 533 | "name": "Ben Quadinaros", 534 | "gender": "male", 535 | "skinColor": "grey, green, yellow", 536 | "hairColor": "none", 537 | "height": 163, 538 | "eyeColor": "orange", 539 | "mass": 65, 540 | "birthYear": null 541 | }, 542 | { 543 | "id": 49, 544 | "name": "Mace Windu", 545 | "gender": "male", 546 | "skinColor": "dark", 547 | "hairColor": "none", 548 | "height": 188, 549 | "eyeColor": "brown", 550 | "mass": 84, 551 | "birthYear": "72BBY" 552 | }, 553 | { 554 | "id": 50, 555 | "name": "Ki-Adi-Mundi", 556 | "gender": "male", 557 | "skinColor": "pale", 558 | "hairColor": "white", 559 | "height": 198, 560 | "eyeColor": "yellow", 561 | "mass": 82, 562 | "birthYear": "92BBY" 563 | }, 564 | { 565 | "id": 51, 566 | "name": "Kit Fisto", 567 | "gender": "male", 568 | "skinColor": "green", 569 | "hairColor": "none", 570 | "height": 196, 571 | "eyeColor": "black", 572 | "mass": 87, 573 | "birthYear": null 574 | }, 575 | { 576 | "id": 52, 577 | "name": "Eeth Koth", 578 | "gender": "male", 579 | "skinColor": "brown", 580 | "hairColor": "black", 581 | "height": 171, 582 | "eyeColor": "brown", 583 | "mass": null, 584 | "birthYear": null 585 | }, 586 | { 587 | "id": 53, 588 | "name": "Adi Gallia", 589 | "gender": "female", 590 | "skinColor": "dark", 591 | "hairColor": "none", 592 | "height": 184, 593 | "eyeColor": "blue", 594 | "mass": 50, 595 | "birthYear": null 596 | }, 597 | { 598 | "id": 54, 599 | "name": "Saesee Tiin", 600 | "gender": "male", 601 | "skinColor": "pale", 602 | "hairColor": "none", 603 | "height": 188, 604 | "eyeColor": "orange", 605 | "mass": null, 606 | "birthYear": null 607 | }, 608 | { 609 | "id": 55, 610 | "name": "Yarael Poof", 611 | "gender": "male", 612 | "skinColor": "white", 613 | "hairColor": "none", 614 | "height": 264, 615 | "eyeColor": "yellow", 616 | "mass": null, 617 | "birthYear": null 618 | }, 619 | { 620 | "id": 56, 621 | "name": "Plo Koon", 622 | "gender": "male", 623 | "skinColor": "orange", 624 | "hairColor": "none", 625 | "height": 188, 626 | "eyeColor": "black", 627 | "mass": 80, 628 | "birthYear": "22BBY" 629 | }, 630 | { 631 | "id": 57, 632 | "name": "Mas Amedda", 633 | "gender": "male", 634 | "skinColor": "blue", 635 | "hairColor": "none", 636 | "height": 196, 637 | "eyeColor": "blue", 638 | "mass": null, 639 | "birthYear": null 640 | }, 641 | { 642 | "id": 58, 643 | "name": "Gregar Typho", 644 | "gender": "male", 645 | "skinColor": "dark", 646 | "hairColor": "black", 647 | "height": 185, 648 | "eyeColor": "brown", 649 | "mass": 85, 650 | "birthYear": null 651 | }, 652 | { 653 | "id": 59, 654 | "name": "Cordé", 655 | "gender": "female", 656 | "skinColor": "light", 657 | "hairColor": "brown", 658 | "height": 157, 659 | "eyeColor": "brown", 660 | "mass": null, 661 | "birthYear": null 662 | }, 663 | { 664 | "id": 60, 665 | "name": "Cliegg Lars", 666 | "gender": "male", 667 | "skinColor": "fair", 668 | "hairColor": "brown", 669 | "height": 183, 670 | "eyeColor": "blue", 671 | "mass": null, 672 | "birthYear": "82BBY" 673 | }, 674 | { 675 | "id": 61, 676 | "name": "Poggle the Lesser", 677 | "gender": "male", 678 | "skinColor": "green", 679 | "hairColor": "none", 680 | "height": 183, 681 | "eyeColor": "yellow", 682 | "mass": 80, 683 | "birthYear": null 684 | }, 685 | { 686 | "id": 62, 687 | "name": "Luminara Unduli", 688 | "gender": "female", 689 | "skinColor": "yellow", 690 | "hairColor": "black", 691 | "height": 170, 692 | "eyeColor": "blue", 693 | "mass": 56.2, 694 | "birthYear": "58BBY" 695 | }, 696 | { 697 | "id": 63, 698 | "name": "Barriss Offee", 699 | "gender": "female", 700 | "skinColor": "yellow", 701 | "hairColor": "black", 702 | "height": 166, 703 | "eyeColor": "blue", 704 | "mass": 50, 705 | "birthYear": "40BBY" 706 | }, 707 | { 708 | "id": 64, 709 | "name": "Dormé", 710 | "gender": "female", 711 | "skinColor": "light", 712 | "hairColor": "brown", 713 | "height": 165, 714 | "eyeColor": "brown", 715 | "mass": null, 716 | "birthYear": null 717 | }, 718 | { 719 | "id": 65, 720 | "name": "Dooku", 721 | "gender": "male", 722 | "skinColor": "fair", 723 | "hairColor": "white", 724 | "height": 193, 725 | "eyeColor": "brown", 726 | "mass": 80, 727 | "birthYear": "102BBY" 728 | }, 729 | { 730 | "id": 66, 731 | "name": "Bail Prestor Organa", 732 | "gender": "male", 733 | "skinColor": "tan", 734 | "hairColor": "black", 735 | "height": 191, 736 | "eyeColor": "brown", 737 | "mass": null, 738 | "birthYear": "67BBY" 739 | }, 740 | { 741 | "id": 67, 742 | "name": "Jango Fett", 743 | "gender": "male", 744 | "skinColor": "tan", 745 | "hairColor": "black", 746 | "height": 183, 747 | "eyeColor": "brown", 748 | "mass": 79, 749 | "birthYear": "66BBY" 750 | }, 751 | { 752 | "id": 68, 753 | "name": "Zam Wesell", 754 | "gender": "female", 755 | "skinColor": "fair, green, yellow", 756 | "hairColor": "blonde", 757 | "height": 168, 758 | "eyeColor": "yellow", 759 | "mass": 55, 760 | "birthYear": null 761 | }, 762 | { 763 | "id": 69, 764 | "name": "Dexter Jettster", 765 | "gender": "male", 766 | "skinColor": "brown", 767 | "hairColor": "none", 768 | "height": 198, 769 | "eyeColor": "yellow", 770 | "mass": 102, 771 | "birthYear": null 772 | }, 773 | { 774 | "id": 70, 775 | "name": "Lama Su", 776 | "gender": "male", 777 | "skinColor": "grey", 778 | "hairColor": "none", 779 | "height": 229, 780 | "eyeColor": "black", 781 | "mass": 88, 782 | "birthYear": null 783 | }, 784 | { 785 | "id": 71, 786 | "name": "Taun We", 787 | "gender": "female", 788 | "skinColor": "grey", 789 | "hairColor": "none", 790 | "height": 213, 791 | "eyeColor": "black", 792 | "mass": null, 793 | "birthYear": null 794 | }, 795 | { 796 | "id": 72, 797 | "name": "Jocasta Nu", 798 | "gender": "female", 799 | "skinColor": "fair", 800 | "hairColor": "white", 801 | "height": 167, 802 | "eyeColor": "blue", 803 | "mass": null, 804 | "birthYear": null 805 | }, 806 | { 807 | "id": 73, 808 | "name": "R4-P17", 809 | "gender": "female", 810 | "skinColor": "silver, red", 811 | "hairColor": "none", 812 | "height": 96, 813 | "eyeColor": "red, blue", 814 | "mass": null, 815 | "birthYear": null 816 | }, 817 | { 818 | "id": 74, 819 | "name": "Wat Tambor", 820 | "gender": "male", 821 | "skinColor": "green, grey", 822 | "hairColor": "none", 823 | "height": 193, 824 | "eyeColor": null, 825 | "mass": 48, 826 | "birthYear": null 827 | }, 828 | { 829 | "id": 75, 830 | "name": "San Hill", 831 | "gender": "male", 832 | "skinColor": "grey", 833 | "hairColor": "none", 834 | "height": 191, 835 | "eyeColor": "gold", 836 | "mass": null, 837 | "birthYear": null 838 | }, 839 | { 840 | "id": 76, 841 | "name": "Shaak Ti", 842 | "gender": "female", 843 | "skinColor": "red, blue, white", 844 | "hairColor": "none", 845 | "height": 178, 846 | "eyeColor": "black", 847 | "mass": 57, 848 | "birthYear": null 849 | }, 850 | { 851 | "id": 77, 852 | "name": "Grievous", 853 | "gender": "male", 854 | "skinColor": "brown, white", 855 | "hairColor": "none", 856 | "height": 216, 857 | "eyeColor": "green, yellow", 858 | "mass": 159, 859 | "birthYear": null 860 | }, 861 | { 862 | "id": 78, 863 | "name": "Tarfful", 864 | "gender": "male", 865 | "skinColor": "brown", 866 | "hairColor": "brown", 867 | "height": 234, 868 | "eyeColor": "blue", 869 | "mass": 136, 870 | "birthYear": null 871 | }, 872 | { 873 | "id": 79, 874 | "name": "Raymus Antilles", 875 | "gender": "male", 876 | "skinColor": "light", 877 | "hairColor": "brown", 878 | "height": 188, 879 | "eyeColor": "brown", 880 | "mass": 79, 881 | "birthYear": null 882 | }, 883 | { 884 | "id": 80, 885 | "name": "Sly Moore", 886 | "gender": "female", 887 | "skinColor": "pale", 888 | "hairColor": "none", 889 | "height": 178, 890 | "eyeColor": "white", 891 | "mass": 48, 892 | "birthYear": null 893 | }, 894 | { 895 | "id": 81, 896 | "name": "Tion Medon", 897 | "gender": "male", 898 | "skinColor": "grey", 899 | "hairColor": "none", 900 | "height": 206, 901 | "eyeColor": "black", 902 | "mass": 80, 903 | "birthYear": null 904 | } 905 | ] 906 | } 907 | --------------------------------------------------------------------------------