├── .eslintignore ├── slides ├── 02.mdx ├── 05.mov ├── 05.png ├── 06.mdx ├── 03.mdx ├── 08.mdx ├── 07.mdx ├── 01.mdx └── 04.mdx ├── .prettierignore ├── public ├── favicon.ico ├── manifest.json └── index.html ├── .prettierrc ├── .gitignore ├── src ├── index.js ├── firebase.js ├── old-app.js └── app.js ├── README.md └── package.json /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | public 5 | .docz 6 | -------------------------------------------------------------------------------- /slides/02.mdx: -------------------------------------------------------------------------------- 1 | ## Please Stand! ️️🏋 2 | 3 | > If you're physically able 💛 ♿️ 4 | -------------------------------------------------------------------------------- /slides/05.mov: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/why-react-hooks/HEAD/slides/05.mov -------------------------------------------------------------------------------- /slides/05.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/why-react-hooks/HEAD/slides/05.png -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | coverage 4 | public 5 | .docz 6 | package-lock.json -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kentcdodds/why-react-hooks/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /slides/06.mdx: -------------------------------------------------------------------------------- 1 | # Demo time! 2 | 3 | Checkout `src/app.js` and compare it to `src/old-app.js` 4 | -------------------------------------------------------------------------------- /slides/03.mdx: -------------------------------------------------------------------------------- 1 | # What this talk is 2 | 3 | - The why of hooks 4 | - Code examples 5 | 6 | # What this talk is not 7 | 8 | - An introduction to hooks 9 | -------------------------------------------------------------------------------- /slides/08.mdx: -------------------------------------------------------------------------------- 1 | # Thank you! 2 | 3 | 👋 4 | 5 | 🐦 @kentcdodds 6 | 7 | 💌 https://kentcdodds.com/subscribe 8 | 9 | Slides: https://github.com/kentcdodds/why-react-hooks 10 | -------------------------------------------------------------------------------- /slides/07.mdx: -------------------------------------------------------------------------------- 1 | # Resources 2 | 3 | - 📑 https://reactjs.org/hooks 4 | - 🥚 https://kcd.im/hooks-and-suspense 5 | - 🏚 ➡ 🏠 https://kcd.im/refactor-react 6 | - 🤓 https://github.com/sw-yx/fresh-concurrent-react 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "jsxBracketSameLine": false, 5 | "printWidth": 80, 6 | "proseWrap": "always", 7 | "semi": false, 8 | "singleQuote": true, 9 | "tabWidth": 2, 10 | "trailingComma": "all", 11 | "useTabs": false 12 | } 13 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /slides/01.mdx: -------------------------------------------------------------------------------- 1 | # Why React Hooks 2 | 3 | > Simplify your app with React Hooks 4 | 5 | 👋 I'm Kent C. Dodds 6 | 7 | - 🏡 Utah 8 | - 👩 👧 👦 👦 👦 🐕 9 | - 🏢 kentcdodds.com 10 | - 🐦/🐙 @kentcdodds 11 | - 🏆 testingjavascript.com 12 | - 🥚 kcd.im/egghead 13 | - 🥋 kcd.im/fem 14 | - 💌 kcd.im/news 15 | - 📝 kcd.im/blog 16 | - 📺 kcd.im/devtips 17 | - 💻 kcd.im/coding 18 | - 📽 kcd.im/youtube 19 | - 🎙 kcd.im/3-mins 20 | - ❓ kcd.im/ama 21 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import 'milligram' 2 | import React, {Suspense} from 'react' 3 | import ErrorBoundary from 'react-error-boundary' 4 | import ReactDOM from 'react-dom' 5 | import App from './app' 6 | // import App from './old-app' 7 | 8 | function ErrorFallback(props) { 9 | console.error(props) 10 | return 'there was an error' 11 | } 12 | 13 | ReactDOM.render( 14 | 15 | 16 | 17 | 18 | , 19 | document.getElementById('root'), 20 | ) 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Why React Hooks? 2 | 3 | This is for my talk 4 | [Why React Hooks?](https://kentcdodds.com/talks/#why-react-hooks) 5 | which is intended to establish a case for why React Hooks 6 | are so useful and you should dedicate some time to learn 7 | about them. 8 | 9 | It consists of "slides" in the `./slides` directory and an 10 | example app that's implemented in both classes and hooks. 11 | 12 | You can find a recording of the talk 13 | [here](https://www.youtube.com/watch?v=zWsZcBiwgVE&list=PLV5CVI1eNcJgNqzNwcs4UKrlJdhfDjshf) 14 | and you can find the deployed app 15 | [here](https://geo-chat.netlify.com/). 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "why-react-hooks", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@firebase/app-types": "0.4.6", 7 | "@firebase/util": "^0.2.30", 8 | "firebase": "7.2.1", 9 | "milligram": "1.3.0", 10 | "react": "16.10.2", 11 | "react-dom": "16.10.2", 12 | "react-error-boundary": "^1.2.5", 13 | "react-fns": "1.4.0", 14 | "react-scripts": "3.2.0" 15 | }, 16 | "scripts": { 17 | "start": "react-scripts start", 18 | "build": "react-scripts build", 19 | "test": "react-scripts test" 20 | }, 21 | "eslintConfig": { 22 | "extends": "react-app" 23 | }, 24 | "browserslist": [ 25 | ">0.2%", 26 | "not dead", 27 | "not ie <= 11", 28 | "not op_mini all" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/firebase.js: -------------------------------------------------------------------------------- 1 | import * as firebase from 'firebase/app' 2 | import 'firebase/database' 3 | 4 | try { 5 | firebase.initializeApp({ 6 | apiKey: 'AIzaSyCXh3HpbB_jhMNuxc1vOsDkUUCIlRBNYm0', 7 | authDomain: 'geo-chat-7d7c6.firebaseapp.com', 8 | databaseURL: 'https://geo-chat-7d7c6.firebaseio.com', 9 | projectId: 'geo-chat-7d7c6', 10 | storageBucket: '', 11 | messagingSenderId: '453185349176', 12 | }) 13 | } catch (e) {} 14 | 15 | function addMessage({latitude, longitude, content, username}) { 16 | const locationId = getLocationId({latitude, longitude}) 17 | firebase 18 | .database() 19 | .ref(`messages/${locationId}/posts`) 20 | .push({ 21 | date: Date.now(), 22 | content, 23 | username, 24 | latitude, 25 | longitude, 26 | }) 27 | } 28 | 29 | function subscribe({latitude, longitude}, callback) { 30 | const locationId = getLocationId({latitude, longitude}) 31 | const ref = firebase.database().ref(`messages/${locationId}/posts`) 32 | console.log('registering with locationid of ', locationId) 33 | ref.on('value', snapshot => 34 | callback( 35 | Object.entries(snapshot.val() || {}).map(([id, data]) => ({id, ...data})), 36 | ), 37 | ) 38 | return () => ref.off('value', callback) 39 | } 40 | 41 | const getLocationId = ({latitude, longitude}) => 42 | `${(latitude * 10).toFixed()}_${(longitude * 10).toFixed()}` 43 | 44 | export {subscribe, addMessage} 45 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /slides/04.mdx: -------------------------------------------------------------------------------- 1 | # What makes React so hard? 2 | 3 | - 📦 JavaScript 4 | - 🔄 Lifecycles 5 | - ⬛️ Logic Reuse (patterns) 6 | 7 | 22 | 23 | ## 📦 JavaScript 24 | 25 | ```javascript 26 | // find the 🐛 27 | import React from 'react' 28 | 29 | class Counter extends React.Component { 30 | constructor() { 31 | super() 32 | this.state = {count: this.props.initialCount} 33 | } 34 | increment() { 35 | this.setState(({count}) => ({count: count + 1})) 36 | } 37 | render() { 38 | return ( 39 | 42 | ) 43 | } 44 | } 45 | ``` 46 | 47 | TRICK QUESTION! THERE WERE TWO 🐛 🐜! DID YOU FIND THEM? 48 | 49 | ## 🔄 Lifecycles 50 | 51 | ```javascript 52 | import React from 'react' 53 | import UserContext from '../user-context' 54 | 55 | class ChatFeed extends React.Component { 56 | static contextType = UserContext 57 | state = {isScrolledToBottom: true} 58 | componentDidMount() { 59 | this.subscribeToFeed() 60 | this.setDocumentTitle() 61 | this.subscribeToOnlineStatus() 62 | this.subscribeToGeoLocation() 63 | } 64 | componentWillUnmount() { 65 | this.unsubscribeFromFeed() 66 | this.restoreDocumentTitle() 67 | this.unsubscribeFromOnlineStatus() 68 | this.unsubscribeFromGeoLocation() 69 | } 70 | componentDidUpdate(prevProps, prevState) { 71 | // ... compare props and re-subscribe etc. 72 | } 73 | render() { 74 | // ... 75 | } 76 | } 77 | ``` 78 | 79 | ## ⬛️ Logic Reuse 80 | 81 | ```javascript 82 | import React from 'react' 83 | import UserContext from '../user-context' 84 | 85 | function ChatFeed({feedId}) { 86 | return ( 87 | 88 | {user => ( 89 | 90 | {posts => ( 91 | <> 92 | 93 | 94 | {isOnline => ( 95 | 96 | {location => ( 97 | <> 98 | 99 | 100 | 101 | 102 | 103 | )} 104 | 105 | )} 106 | 107 | 108 | )} 109 | 110 | )} 111 | 112 | ) 113 | } 114 | ``` 115 | 116 | ... LOL 😂🤣 ... 😢😭😭😭😭 117 | -------------------------------------------------------------------------------- /src/old-app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {withGeoPosition} from 'react-fns' 3 | import * as firebase from './firebase' 4 | 5 | function checkInView(element, container = element.parentElement) { 6 | const cTop = container.scrollTop 7 | const cBottom = cTop + container.clientHeight 8 | const eTop = element.offsetTop - container.offsetTop 9 | const eBottom = eTop + element.clientHeight 10 | const isTotal = eTop >= cTop && eBottom <= cBottom 11 | const isPartial = 12 | (eTop < cTop && eBottom > cTop) || (eBottom > cBottom && eTop < cBottom) 13 | return isTotal || isPartial 14 | } 15 | 16 | class App extends React.Component { 17 | static defaultProps = { 18 | latitude: 40, 19 | longitude: -111, 20 | } 21 | state = { 22 | username: window.localStorage.getItem('geo-chat:username'), 23 | messages: [], 24 | seenNodes: [], 25 | isStuck: true, 26 | } 27 | messagesContainerRef = React.createRef() 28 | sendMessage = e => { 29 | e.preventDefault() 30 | firebase.addMessage({ 31 | latitude: this.props.latitude, 32 | longitude: this.props.longitude, 33 | username: this.state.username || 'anonymous', 34 | content: e.target.elements.message.value, 35 | }) 36 | e.target.elements.message.value = '' 37 | e.target.elements.message.focus() 38 | } 39 | componentDidMount() { 40 | this.trackVisibleChildren() 41 | this.trackStuck() 42 | this.updateDocumentTitle() 43 | this.subscribeToFirebase() 44 | } 45 | componentDidUpdate(prevProps, prevState) { 46 | this.trackVisibleChildren() 47 | if ( 48 | this.state.messages.length !== prevState.messages && 49 | this.state.isStuck 50 | ) { 51 | this.stickContainer() 52 | } 53 | this.updateDocumentTitle() 54 | if ( 55 | prevProps.longitude !== this.props.longitude || 56 | prevProps.latitude !== this.props.latitude 57 | ) { 58 | this.unsubscribeFromFirebase() 59 | this.subscribeToFirebase() 60 | } 61 | } 62 | updateDocumentTitle() { 63 | const unreadCount = this.state.messages.length - this.state.seenNodes.length 64 | document.title = unreadCount ? `Unread: ${unreadCount}` : 'All read' 65 | console.log(document.title) 66 | } 67 | stickContainer() { 68 | this.messagesContainerRef.current.scrollTop = this.messagesContainerRef.current.scrollHeight 69 | } 70 | subscribeToFirebase() { 71 | const {latitude, longitude} = this.props 72 | this.unsubscribeFromFirebase = firebase.subscribe( 73 | {latitude, longitude}, 74 | messages => { 75 | this.setState({messages: messages}) 76 | }, 77 | ) 78 | } 79 | trackStuck() { 80 | const handleScroll = () => { 81 | const { 82 | clientHeight, 83 | scrollTop, 84 | scrollHeight, 85 | } = this.messagesContainerRef.current 86 | const partialPixelBuffer = 10 87 | const scrolledUp = 88 | clientHeight + scrollTop < scrollHeight - partialPixelBuffer 89 | console.log(scrolledUp) 90 | this.setState({isStuck: !scrolledUp}) 91 | } 92 | this.messagesContainerRef.current.addEventListener('scroll', handleScroll) 93 | this.untrackStuck = () => 94 | this.messagesContainerRef.current.removeEventListener( 95 | 'scroll', 96 | handleScroll, 97 | ) 98 | } 99 | trackVisibleChildren() { 100 | const newVisibleChildren = Array.from( 101 | this.messagesContainerRef.current.children, 102 | ) 103 | .filter(n => !this.state.seenNodes.includes(n)) 104 | .filter(n => checkInView(n, this.messagesContainerRef.current)) 105 | if (newVisibleChildren.length) { 106 | this.setState(({seenNodes}) => ({ 107 | seenNodes: Array.from(new Set([...seenNodes, ...newVisibleChildren])), 108 | })) 109 | } 110 | } 111 | handleUsernameChange = e => { 112 | const username = e.target.value 113 | this.setState({username}) 114 | window.localStorage.setItem('geo-chat:username', username) 115 | } 116 | componentWillUnmount() { 117 | this.untrackStuck() 118 | this.unsubscribeFromFirebase() 119 | } 120 | render() { 121 | const {latitude, longitude} = this.props 122 | return ( 123 |
124 | 125 | 131 |
132 | 133 | 134 | 135 |
136 |
{JSON.stringify({latitude, longitude}, null, 2)}
137 |
148 | {this.state.messages.map(message => ( 149 |
150 | {message.username}: {message.content} 151 |
152 | ))} 153 |
154 |
155 | ) 156 | } 157 | } 158 | 159 | export default withGeoPosition(({isLoading, coords, error}) => 160 | isLoading ? 'loading...' : error ? 'there was an error' : , 161 | ) 162 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import * as firebase from './firebase' 3 | 4 | function App() { 5 | const { 6 | coords: {latitude, longitude}, 7 | } = useGeoPosition() 8 | const messagesContainerRef = React.useRef() 9 | const [messages, setMessages] = React.useState([]) 10 | const [username, setUsername] = useLocalStorageState('', { 11 | key: 'geo-chat:username', 12 | }) 13 | useStickyScrollContainer(messagesContainerRef, [messages.length]) 14 | const visibleNodes = useVisibilityCounter(messagesContainerRef) 15 | const unreadCount = messages.length - visibleNodes.length 16 | 17 | React.useEffect(() => { 18 | const unsubscribe = firebase.subscribe({latitude, longitude}, messages => { 19 | setMessages(messages) 20 | }) 21 | return () => { 22 | unsubscribe() 23 | } 24 | }, [latitude, longitude]) 25 | 26 | React.useEffect(() => { 27 | document.title = unreadCount ? `Unread: ${unreadCount}` : 'All read' 28 | }, [unreadCount]) 29 | 30 | const initialDocumentTitle = React.useRef(document.title) 31 | React.useEffect(() => { 32 | return () => { 33 | document.title = initialDocumentTitle 34 | } 35 | }, []) 36 | 37 | function sendMessage(e) { 38 | e.preventDefault() 39 | firebase.addMessage({ 40 | latitude, 41 | longitude, 42 | username: username || 'anonymous', 43 | content: e.target.elements.message.value, 44 | }) 45 | e.target.elements.message.value = '' 46 | e.target.elements.message.focus() 47 | } 48 | 49 | function handleUsernameChange(e) { 50 | const username = e.target.value 51 | setUsername(username) 52 | } 53 | 54 | return ( 55 |
56 | 57 | 63 |
64 | 65 | 66 | 67 |
68 |
{JSON.stringify({latitude, longitude}, null, 2)}
69 |
80 | {messages.map(message => ( 81 |
82 | {message.username}: {message.content} 83 |
84 | ))} 85 |
86 |
87 | ) 88 | } 89 | 90 | function useLocalStorageState( 91 | initialValue, 92 | {key, serializer = v => v, deserializer = v => v}, 93 | ) { 94 | const [state, setState] = React.useState( 95 | () => deserializer(window.localStorage.getItem(key)) || initialValue, 96 | ) 97 | 98 | const serializedState = serializer(state) 99 | React.useEffect(() => { 100 | window.localStorage.setItem(key, serializedState) 101 | }, [key, serializedState]) 102 | 103 | return [state, setState] 104 | } 105 | 106 | function useGeoPosition(options) { 107 | const [position, setPosition] = React.useState(getInitialPosition(options)) 108 | 109 | const setError = useErrorBoundaryError() 110 | 111 | React.useEffect(() => { 112 | const watch = navigator.geolocation.watchPosition( 113 | setPosition, 114 | setError, 115 | options, 116 | ) 117 | 118 | return () => navigator.geolocation.clearWatch(watch) 119 | }, [options, setError]) 120 | 121 | return position 122 | } 123 | 124 | function useStickyScrollContainer(scrollContainerRef, inputs = []) { 125 | const [isStuck, setStuck] = React.useState(true) 126 | React.useEffect(() => { 127 | const scrollContainer = scrollContainerRef.current 128 | function handleScroll() { 129 | const {clientHeight, scrollTop, scrollHeight} = scrollContainer 130 | const partialPixelBuffer = 10 131 | const scrolledUp = 132 | clientHeight + scrollTop < scrollHeight - partialPixelBuffer 133 | setStuck(!scrolledUp) 134 | } 135 | scrollContainer.addEventListener('scroll', handleScroll) 136 | return () => scrollContainer.removeEventListener('scroll', handleScroll) 137 | }, [scrollContainerRef]) 138 | 139 | const scrollHeight = scrollContainerRef.current 140 | ? scrollContainerRef.current.scrollHeight 141 | : 0 142 | 143 | React.useEffect(() => { 144 | const scrollContainer = scrollContainerRef.current 145 | if (isStuck) { 146 | scrollContainer.scrollTop = scrollHeight 147 | } 148 | // ignoring this rule is dangerous and not recommended 149 | // but sometimes it's the only way you can create the API you want. 150 | // 99% of the time you should not disable this rule. 151 | // eslint-disable-next-line react-hooks/exhaustive-deps 152 | }, [isStuck, scrollContainerRef, scrollHeight, ...inputs]) 153 | 154 | return isStuck 155 | } 156 | 157 | function checkInView(element, container = element.parentElement) { 158 | const cTop = container.scrollTop 159 | const cBottom = cTop + container.clientHeight 160 | const eTop = element.offsetTop - container.offsetTop 161 | const eBottom = eTop + element.clientHeight 162 | const isTotal = eTop >= cTop && eBottom <= cBottom 163 | const isPartial = 164 | (eTop < cTop && eBottom > cTop) || (eBottom > cBottom && eTop < cBottom) 165 | return isTotal || isPartial 166 | } 167 | 168 | function useVisibilityCounter(containerRef) { 169 | const [seenNodes, setSeenNodes] = React.useState([]) 170 | 171 | React.useEffect(() => { 172 | const newVisibleChildren = Array.from(containerRef.current.children) 173 | .filter(n => !seenNodes.includes(n)) 174 | .filter(n => checkInView(n, containerRef.current)) 175 | if (newVisibleChildren.length) { 176 | setSeenNodes(seen => 177 | Array.from(new Set([...seen, ...newVisibleChildren])), 178 | ) 179 | } 180 | }, [containerRef, seenNodes]) 181 | 182 | return seenNodes 183 | } 184 | 185 | let initialPosition 186 | function getInitialPosition(options) { 187 | if (!initialPosition) { 188 | // supsense magic... 189 | throw new Promise((resolve, reject) => { 190 | navigator.geolocation.getCurrentPosition( 191 | position => { 192 | initialPosition = position 193 | resolve(position) 194 | }, 195 | error => reject(error), 196 | options, 197 | ) 198 | }) 199 | } 200 | return initialPosition 201 | } 202 | 203 | function useErrorBoundaryError() { 204 | const [error, setError] = React.useState(null) 205 | 206 | if (error) { 207 | // clear out the error 208 | setError(null) 209 | // let the error boundary catch this 210 | throw error 211 | } 212 | 213 | return setError 214 | } 215 | 216 | export default App 217 | --------------------------------------------------------------------------------