├── .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 |
356 | 357 |
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 | 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 |
22 | 23 |
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 | --------------------------------------------------------------------------------