├── .gitignore
├── .prettierrc
├── README.md
├── package-lock.json
├── package.json
├── public
└── index.html
└── src
├── CharacterList.js
├── CharacterListItem.js
├── CharacterSearch.js
├── CharacterView.js
├── dummy-data.js
├── endpoint.js
├── index.js
└── styles.scss
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | *.log
4 | *.un~
5 |
--------------------------------------------------------------------------------
/.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 | "fluid": false
11 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Star Wars Autocomplete
2 |
3 | ## Course
4 |
5 | This project was built to teach the [React State](#) for Frontend Masters.
6 |
7 | ## The Basics
8 |
9 | If we wanted to fetch all of the characters, we could do something like this.
10 |
11 | ```js
12 | useEffect(() => {
13 | console.log('Fetching');
14 | fetch(`${endpoint}/characters`)
15 | .then(response => response.json())
16 | .then(response => {
17 | console.log({ response });
18 | setCharacters(Object.values(response.characters));
19 | })
20 | .catch(console.error);
21 | });
22 | ```
23 |
24 | A careful eye will see that this is actually triggering an infinite loop since we're calling this effect on every single render.
25 |
26 | Adding a bracket will make this less bad.
27 |
28 | ```js
29 | useEffect(() => {
30 | console.log('Fetching');
31 | fetch(`${endpoint}/characters`)
32 | .then(response => response.json())
33 | .then(response => {
34 | console.log({ response });
35 | setCharacters(Object.values(response.characters));
36 | })
37 | .catch(console.error);
38 | }, []);
39 | ```
40 |
41 | This has its own catch since this will only run once when the component mounts and will never run again.
42 |
43 | But we can burn that bridge later.
44 |
45 | Okay, but we want this to run every time the search term changes.
46 |
47 | ## Adding Loading and Error States
48 |
49 | We'll need to keep track of that state.
50 |
51 | ```js
52 | const [loading, setLoading] = useState(true);
53 | const [error, setError] = useState(error);
54 | ```
55 |
56 | Updating the fetch effect.
57 |
58 | ```js
59 | useEffect(() => {
60 | console.log('Fetching');
61 |
62 | setLoading(true);
63 | setError(null);
64 | setCharacters([]);
65 |
66 | fetch(`${endpoint}/characters`)
67 | .then(response => response.json())
68 | .then(response => {
69 | setCharacters(Object.values(response.characters));
70 | setLoading(false);
71 | })
72 | .catch(error => {
73 | setError(error);
74 | setLoading(false);
75 | });
76 | }, []);
77 | ```
78 |
79 | ### Displaying It In The Component
80 |
81 | ```js
82 |
83 | {loading ? (
84 | Loading…
85 | ) : (
86 |
87 | )}
88 | {error && {error.message}
}
89 |
90 | ```
91 |
92 | ## Creating a Custom Hook
93 |
94 | ```js
95 | const useFetch = url => {
96 | const [response, setResponse] = useState(null);
97 | const [loading, setLoading] = useState(true);
98 | const [error, setError] = useState(null);
99 |
100 | useEffect(() => {
101 | console.log('Fetching');
102 |
103 | setLoading(true);
104 | setError(null);
105 | setResponse(null);
106 |
107 | fetch(url)
108 | .then(response => response.json())
109 | .then(response => {
110 | setResponse(response);
111 | setLoading(false);
112 | })
113 | .catch(error => {
114 | setError(error);
115 | setLoading(false);
116 | });
117 | }, [url]);
118 |
119 | return [response, loading, error];
120 | };
121 | ```
122 |
123 | ### Adding the Formatting in There
124 |
125 | Let's break that out into a function.
126 |
127 | ```js
128 | const formatData = response => (response && response.characters) || [];
129 | ```
130 |
131 | Then we can use it like this.
132 |
133 | ```js
134 | const [characters, loading, error] = useFetch(
135 | endpoint + '/characters',
136 | formatData,
137 | );
138 | ```
139 |
140 | We can add that to our `useEffect`.
141 |
142 | ```js
143 | useEffect(() => {
144 | console.log('Fetching');
145 |
146 | setLoading(true);
147 | setError(null);
148 | setResponse(null);
149 |
150 | fetch(url)
151 | .then(response => response.json())
152 | .then(response => {
153 | setResponse(formatData(response));
154 | setLoading(false);
155 | })
156 | .catch(error => {
157 | setError(error);
158 | setLoading(false);
159 | });
160 | }, [url, formatData]);
161 | ```
162 |
163 | ## Using an Async Function
164 |
165 | You can't pass an async function directly to `useEffect`.
166 |
167 | ```js
168 | useEffect(() => {
169 | console.log('Fetching');
170 |
171 | setLoading(true);
172 | setError(null);
173 | setResponse(null);
174 |
175 | const get = async () => {
176 | try {
177 | const response = await fetch(url);
178 | const data = await response.json();
179 | setResponse(formatData(data));
180 | } catch (error) {
181 | setError(error);
182 | } finally {
183 | setLoading(false);
184 | }
185 | };
186 |
187 | get();
188 | }, [url, formatData]);
189 |
190 | return [response, loading, error];
191 | };
192 | ```
193 |
194 | I don't like this, but you might.
195 |
196 | ## Refactoring to a Reducer
197 |
198 | ```js
199 | const fetchReducer = (state, action) => {
200 | if (action.type === 'FETCHING') {
201 | return {
202 | result: null,
203 | loading: true,
204 | error: null,
205 | };
206 | }
207 |
208 | if (action.type === 'RESPONSE_COMPLETE') {
209 | return {
210 | result: action.payload.result,
211 | loading: false,
212 | error: null,
213 | };
214 | }
215 |
216 | if (action.type === 'ERROR') {
217 | return {
218 | result: null,
219 | loading: false,
220 | error: action.payload.error,
221 | };
222 | }
223 |
224 | return state;
225 | };
226 | ```
227 |
228 | Now, we can just dispatch actions.
229 |
230 | ```js
231 | const useFetch = (url, dependencies = [], formatResponse = () => {}) => {
232 | const [state, dispatch] = useReducer(fetchReducer, initialState);
233 |
234 | useEffect(() => {
235 | dispatch({ type: 'FETCHING' });
236 | fetch(url)
237 | .then(response => response.json())
238 | .then(response => {
239 | dispatch({
240 | type: 'RESPONSE_COMPLETE',
241 | payload: { result: formatResponse(response) },
242 | });
243 | })
244 | .catch(error => {
245 | dispatch({ type: 'ERROR', payload: { error } });
246 | });
247 | }, [url, formatResponse]);
248 |
249 | const { result, loading, error } = state;
250 |
251 | return [result, loading, error];
252 | };
253 | ```
254 |
255 | ## Dispensing Asynchronous Actions
256 |
257 | **Important**: We're going to check out the `asynchronous-actions` branch.
258 |
259 | How could we right a simple thunk reducer?
260 |
261 | ```js
262 | const useThunkReducer = (reducer, initialState) => {
263 | const [state, dispatch] = useReducer(reducer, initialState);
264 |
265 | const enhancedDispatch = useCallback(
266 | action => {
267 | if (typeof action === 'function') {
268 | console.log('It is a thunk');
269 | action(dispatch);
270 | } else {
271 | dispatch(action);
272 | }
273 | },
274 | [dispatch],
275 | );
276 |
277 | return [state, enhancedDispatch];
278 | };
279 | ```
280 |
281 | Now, we just use that reducer instead.
282 |
283 | We can have a totally separate function for fetching the data that our state management doesn't know anything about.
284 |
285 | ```js
286 | const fetchCharacters = dispatch => {
287 | dispatch({ type: 'FETCHING' });
288 | fetch(endpoint + '/characters')
289 | .then(response => response.json())
290 | .then(response => {
291 | dispatch({
292 | type: 'RESPONSE_COMPLETE',
293 | payload: {
294 | characters: response.characters,
295 | },
296 | });
297 | })
298 | .catch(error => dispatch({ type: error, payload: { error } }));
299 | };
300 | ```
301 |
302 | #### Exercise: Implementing Character Search
303 |
304 | There is a `CharacterSearch` component. Can you you implement a feature where we update the list based on the search field?
305 |
306 | ```js
307 | import React from 'react';
308 | import endpoint from './endpoint';
309 |
310 | const SearchCharacters = React.memo(({ dispatch }) => {
311 | const [query, setQuery] = React.useState('');
312 |
313 | React.useEffect(() => {
314 | dispatch({ type: 'FETCHING' });
315 | fetch(endpoint + '/search/' + query)
316 | .then(response => response.json())
317 | .then(response => {
318 | dispatch({
319 | type: 'RESPONSE_COMPLETE',
320 | payload: {
321 | characters: response.characters,
322 | },
323 | });
324 | })
325 | .catch(error => dispatch({ type: error, payload: { error } }));
326 | }, [query, dispatch]);
327 |
328 | return (
329 | setQuery(event.target.value)}
331 | placeholder="Search Here"
332 | type="search"
333 | value={query}
334 | />
335 | );
336 | });
337 |
338 | export default SearchCharacters;
339 | ```
340 |
341 | ## The Perils of `useEffect` and Dependencies
342 |
343 | We're going to need to important more things.
344 |
345 | ```js
346 | import { BrowserRouter as Router, Route } from 'react-router-dom';
347 |
348 | import CharacterList from './CharacterList';
349 | import CharacterView from './CharacterView';
350 | ```
351 |
352 | Now, we'll add this little tidbit.
353 |
354 | ```js
355 |
358 | ```
359 |
360 | In `CharacterView`, we'll do the following refactoring:
361 |
362 | ```js
363 | const CharacterView = ({ match }) => {
364 | const [character, setCharacter] = useState({});
365 |
366 | useEffect(() => {
367 | fetch(endpoint + '/characters/' + match.params.id)
368 | .then(response => response.json())
369 | .then(response => setCharacter(response.character))
370 | .catch(console.error);
371 | }, []);
372 |
373 | // …
374 | };
375 | ```
376 |
377 | I have an ESLint plugin that solves most of this for us.
378 |
379 | ```js
380 | const CharacterView = ({ match }) => {
381 | const [character, setCharacter] = useState({});
382 |
383 | useEffect(() => {
384 | fetch(endpoint + '/characters/' + match.params.id)
385 | .then(response => response.json())
386 | .then(response => setCharacter(response.character))
387 | .catch(console.error);
388 | }, []);
389 |
390 | // …
391 | };
392 | ```
393 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "star-wars-characters",
3 | "version": "1.0.0",
4 | "description": "An example application for playing with asynchronous data in a React and Redux application.",
5 | "keywords": [],
6 | "main": "src/index.js",
7 | "dependencies": {
8 | "lodash": "4.17.15",
9 | "node-sass": "^4.13.0",
10 | "react": "16.12.0",
11 | "react-dom": "16.12.0",
12 | "react-redux": "7.1.3",
13 | "react-router-dom": "^5.1.2",
14 | "react-scripts": "3.2.0",
15 | "redux": "4.0.4",
16 | "redux-observable": "1.2.0",
17 | "redux-saga": "1.1.3",
18 | "redux-thunk": "2.3.0",
19 | "rxjs": "6.5.3"
20 | },
21 | "scripts": {
22 | "start": "react-scripts start",
23 | "build": "react-scripts build",
24 | "test": "react-scripts test --env=jsdom",
25 | "eject": "react-scripts eject"
26 | },
27 | "browserslist": [
28 | ">0.2%",
29 | "not dead",
30 | "not ie <= 11",
31 | "not op_mini all"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Star Wars Characters
10 |
11 |
12 |
13 |
16 |
17 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/src/CharacterList.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import CharacterListItem from './CharacterListItem';
4 |
5 | const CharacterList = ({ characters = [] }) => {
6 | return (
7 |
8 | {characters.map(character => (
9 |
10 | ))}
11 |
12 | );
13 | };
14 |
15 | export default CharacterList;
16 |
--------------------------------------------------------------------------------
/src/CharacterListItem.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { NavLink } from 'react-router-dom';
4 |
5 | const CharacterListItem = ({ character }) => {
6 | const { id, name } = character;
7 | return (
8 |
9 |
10 | {name}
11 |
12 |
13 | );
14 | };
15 |
16 | export default CharacterListItem;
17 |
--------------------------------------------------------------------------------
/src/CharacterSearch.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const SearchCharacters = () => {
4 | return (
5 | console.log(event.target.value)}
7 | placeholder="Search Here"
8 | type="search"
9 | value={''}
10 | />
11 | );
12 | };
13 |
14 | export default SearchCharacters;
15 |
--------------------------------------------------------------------------------
/src/CharacterView.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const CharacterView = ({ character = {} }) => {
4 | console.log(character);
5 | return (
6 |
7 | {character.name}
8 |
9 | -
10 | Birth Year: {character.birthYear}
11 |
12 | -
13 | Eye Color: {character.eyeColor}
14 |
15 | -
16 | Gender: {character.gender}
17 |
18 | -
19 | Hair Color: {character.hairColor}
20 |
21 | -
22 | Heigh: {character.height}
23 |
24 | -
25 | Mass: {character.mass}
26 |
27 | -
28 | Skin Color: {character.skinColor}
29 |
30 |
31 |
32 | );
33 | };
34 |
35 | export default CharacterView;
36 |
--------------------------------------------------------------------------------
/src/dummy-data.js:
--------------------------------------------------------------------------------
1 | const dummyData = [
2 | {
3 | id: 1,
4 | name: 'Luke Skywalker',
5 | gender: 'male',
6 | skinColor: 'fair',
7 | hairColor: 'blond',
8 | height: 172,
9 | eyeColor: 'blue',
10 | mass: 77,
11 | birthYear: '19BBY',
12 | },
13 | {
14 | id: 2,
15 | name: 'C-3PO',
16 | gender: 'n/a',
17 | skinColor: 'gold',
18 | hairColor: 'n/a',
19 | height: 167,
20 | eyeColor: 'yellow',
21 | mass: 75,
22 | birthYear: '112BBY',
23 | },
24 | {
25 | id: 3,
26 | name: 'R2-D2',
27 | gender: 'n/a',
28 | skinColor: 'white, blue',
29 | hairColor: 'n/a',
30 | height: 96,
31 | eyeColor: 'red',
32 | mass: 32,
33 | birthYear: '33BBY',
34 | },
35 | {
36 | id: 4,
37 | name: 'Darth Vader',
38 | gender: 'male',
39 | skinColor: 'white',
40 | hairColor: 'none',
41 | height: 202,
42 | eyeColor: 'yellow',
43 | mass: 136,
44 | birthYear: '41.9BBY',
45 | },
46 | {
47 | id: 5,
48 | name: 'Leia Organa',
49 | gender: 'female',
50 | skinColor: 'light',
51 | hairColor: 'brown',
52 | height: 150,
53 | eyeColor: 'brown',
54 | mass: 49,
55 | birthYear: '19BBY',
56 | },
57 | {
58 | id: 6,
59 | name: 'Owen Lars',
60 | gender: 'male',
61 | skinColor: 'light',
62 | hairColor: 'brown, grey',
63 | height: 178,
64 | eyeColor: 'blue',
65 | mass: 120,
66 | birthYear: '52BBY',
67 | },
68 | {
69 | id: 7,
70 | name: 'Beru Whitesun lars',
71 | gender: 'female',
72 | skinColor: 'light',
73 | hairColor: 'brown',
74 | height: 165,
75 | eyeColor: 'blue',
76 | mass: 75,
77 | birthYear: '47BBY',
78 | },
79 | {
80 | id: 8,
81 | name: 'R5-D4',
82 | gender: 'n/a',
83 | skinColor: 'white, red',
84 | hairColor: 'n/a',
85 | height: 97,
86 | eyeColor: 'red',
87 | mass: 32,
88 | birthYear: null,
89 | },
90 | ];
91 |
92 | export default dummyData;
93 |
--------------------------------------------------------------------------------
/src/endpoint.js:
--------------------------------------------------------------------------------
1 | export default 'https://star-wars-character-search.glitch.me/api';
2 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import ReactDOM from 'react-dom';
3 |
4 | import { BrowserRouter as Router } from 'react-router-dom';
5 |
6 | import CharacterList from './CharacterList';
7 |
8 | import dummyData from './dummy-data';
9 |
10 | import './styles.scss';
11 |
12 | const Application = () => {
13 | const [characters, setCharacters] = useState(dummyData);
14 |
15 | return (
16 |
17 |
18 | Star Wars Characters
19 |
20 |
21 |
24 |
25 |
26 | );
27 | };
28 |
29 | const rootElement = document.getElementById('root');
30 |
31 | ReactDOM.render(
32 |
33 |
34 | ,
35 | rootElement,
36 | );
37 |
--------------------------------------------------------------------------------
/src/styles.scss:
--------------------------------------------------------------------------------
1 | $button-color: rgb(28, 209, 83);
2 | $danger-color: #f0544f;
3 | $control-color: #c79528;
4 | $font-color: #28283d;
5 |
6 | @mixin input-style($color) {
7 | background-color: $color;
8 | border: 1px solid darken($color, 10%);
9 | margin: 0.5em 0;
10 | outline: none;
11 | padding: 1em;
12 | width: 100%;
13 | &:focus {
14 | background-color: lighten($color, 2%);
15 | }
16 | }
17 |
18 | @mixin button-style($color) {
19 | @include input-style($color);
20 | &:hover {
21 | background-color: lighten($color, 5%);
22 | }
23 | &:active {
24 | background-color: darken($color, 5%);
25 | }
26 | &:disabled {
27 | background-color: lighten($color, 10%);
28 | border-color: $color;
29 | color: $color;
30 | }
31 | }
32 |
33 | @mixin box-shadow() {
34 | box-shadow: 1px 2px 3px rgba(0, 0, 0, 0.2);
35 | }
36 |
37 | html,
38 | body,
39 | * {
40 | box-sizing: border-box;
41 | }
42 |
43 | body {
44 | margin: 0;
45 | }
46 |
47 | body,
48 | input {
49 | color: $font-color;
50 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
51 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
52 | sans-serif;
53 | -webkit-font-smoothing: antialiased;
54 | -moz-osx-font-smoothing: grayscale;
55 | }
56 |
57 | code {
58 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
59 | monospace;
60 | }
61 |
62 | select {
63 | border-radius: 0;
64 | color: $font-color;
65 | margin: 1em 0;
66 | padding: 1em;
67 | width: 100%;
68 | }
69 |
70 | option {
71 | padding: 1em;
72 | }
73 |
74 | input {
75 | @include input-style(white);
76 | }
77 |
78 | button,
79 | input[type='submit'] {
80 | @include button-style($button-color);
81 | &.danger {
82 | @include button-style($danger-color);
83 | }
84 | }
85 |
86 | .Application {
87 | h1 {
88 | @include box-shadow();
89 | padding: 1em;
90 | border: 1px solid $control-color;
91 | text-align: center;
92 | color: lighten($font-color, 10%);
93 | }
94 | margin: 0.5em auto;
95 | max-width: 800px;
96 |
97 | main {
98 | display: flex;
99 | }
100 | }
101 |
102 | .sidebar {
103 | width: 100%;
104 | flex: 1;
105 | }
106 |
107 | .CharacterView {
108 | padding: 0 2em;
109 | flex: 3;
110 | width: 100%;
111 | }
112 |
113 | .CharacterListItem {
114 | padding: 0.5em;
115 | }
116 |
117 | .CharacterListItemLink {
118 | color: black;
119 | text-decoration: none;
120 | border: 1px solid $button-color;
121 | display: block;
122 | padding: 1em;
123 | &.active {
124 | background-color: lighten($button-color, 30%);
125 | }
126 | }
127 |
128 | .CharacterDetails {
129 | list-style: none;
130 | padding: 0;
131 | li {
132 | padding: 1em;
133 | }
134 | }
135 |
--------------------------------------------------------------------------------