React Introduction
- when writing react, we write functions called “components”
- these components return “jsx” which looks like html
- this jsx can contain either regular html elements or other components
- typically, html elements start with a small letter while components start with a capital letter
- with react, we make the html “dynamic” based on user interaction
- how does it work - user requests the html page, which inturn requests for our javascript file and styles
- the browser does not understand jsx. “babel” helps “transpile” it into js
- additionally, “webpack” combines the multiple javascript files into a single “bundle” javascript file
- now, these lines of index.js is what enables our react app to render our app component inside the user’s browser -
1 2 3 4
const rootElement = document.getElementById("root"); const root = createRoot(rootElement); root.render(...);
- to generate an app, run
npx create-react-app jsx
. to run the app, runnpm start
, and access the application at localhost:3000 - note - create react has been deprecated, so we can use vite instead
- to generate an app using vite, use
npm create vite jsx -- --template=react
, and run it usingnpm run dev
instead, and access the application at localhost:5173 - we see two libraries - react and react-dom. react has the core functionality related to components, while react dom is specific to browsers. this way, we can use react for mobile devices as well using react-native instead
JSX
- e.g. go to this url and type
<h1>Hi There</h1>
. we can see it transpile to the below -1 2 3 4
import { jsx as _jsx } from "react/jsx-runtime"; /*#__PURE__*/_jsx("h1", { children: "Hi There" });
- jsx makes reading and writing code much easier
- use curly braces to use javascript expressions inside the jsx
- “attributes” of html are called “props” (short for properties) in react
- note - if passing a string, we do not need curly braces. for other types we do
1 2 3 4 5 6 7 8 9
function App() { return ( <input type="number" min={5} max={10} /> ) }
- for passing in “true” for a prop, just passing in the prop name without the value is enough
class
attribute of html elements becomesclassName
(to avoid clash with the javascript class)- some attributes are changed to camel case when writing jsx, e.g.
autofocus
in html becomesautoFocus
- for styles, instead of a simple string like in html, we specify an object in jsx -
style=(( padding: "15px" ))
- for arrays, react prints out the concatenated version of it, e.g.
{[1, 2, 3, 4, 5]}
will appear as12345
- for strings, react shows them as is, without the quotations
- however, react does not print boolean, undefined, null. it just ignores them
- now, about vanilla javascript
||
- returns the first truthy value. if none present, it will return the last falsy value&&
- returns the first falsy value. if none present, it will return the last truthy value
- we can use this for “conditional rendering” - e.g. if we render
{condition && <Element />}
- if the condition is falsy, it would be returned, and react would not render anything - because recall that react does not render booleans, undefined or null
- if the condition is satisfied, the element after it would be returned, since it is the last truthy value
- similarly, to render a default in case of a missing value, we can do the following -
<h1>Hi { name || "anonymous" }</h1>
. the first truthy value is returned - either the name or the default
Props
- we organize our components using a “component hierarchy”
- so, we have relationships like “parent”, “children” and “siblings”
- the “props system” is a one way flow of data - we pass the data from parent to children
1 2
<ProfileCard title="Alexa" handle="@alexa99" /> <ProfileCard title="Cortana" handle="@cortana32" />
- all the props we pass are collected into an object, and passed as the first argument to the child
1 2 3 4 5 6 7 8
function ProfileCard({ title, handle }) { return ( <div> <div>{title}</div> <div>{handle}</div> </div> ) }
- install react developer tools, which helps us highlight the different components on the page, inspect the props being passed around, etc
- working with local images in react -
1 2 3
import AlexaImage from "./images/alexa.png"; // ... <img src={AlexaImage} />
- remember to add “alt” attributes to images. they are used by “screen readers” (convert content on screen to audio), and help with accessibility
- if we for e.g. have a custom
Button
component, and we wrap it around some jsx like this -1 2 3
<Button> Click Here! </Button>
- this text / jsx would be received as the “children” prop inside the Button component
1 2 3 4 5
function Button({ children }) { return ( <button>{children}</button> ) }
Prop Types
- “prop types” help us introduce validation to props. it is for development purpose only
- imagine that to this custom button component, we provide “boolean” props like primary, secondary, success, danger, etc to decide the button type, as opposed to passing a string prop of “variant”
- we would like to ensure that at a time, only one of primary, secondary, etc is passed in as true
- additionally, we would like to assert that only boolean is passed for outline / rounded
- we either pass in anything as the key, and a function as the value. in this case, the first argument passed to the function are all the props. we can then perform some complex logic here, and on failure, we return an error
- or, we specify the “prop name” for the key, and the value is the “expected prop type”. we can chain
isRequired
to it as well1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
import PropTypes from 'prop-types'; function Button({ children, primary, secondary, success, warning, danger, outline, rounded }) { // ... } export default Button; Button.propTypes = { verifyVariation: ({ primary, secondary, success, warning, danger }) => { const variationCount = !!primary + !!secondary + !!success + !!warning + !!danger; if (variationCount > 1) { return new Error("only one of primary, secondary, success, warning, danger can be true") } }, outline: PropTypes.bool, rounded: PropTypes.bool.isRequired, };
- with these prop types in place, we get an error if we try using the following -
1
<Button success warning rounded outline>Click Here!</Button>
- note - usually, we use typescript instead of prop types, as can handle this and much more using it
Classnames
- we can install it using
npm i classnames
- “classnames” library - helps build a class name string easily, instead of us manually using javascript and string concatenation
- for static - just pass in a string
- for dynamic - pass in an object - the key is the class name to apply, and the class name gets applied based on if the value is truthy or not
- e.g. based on our style of passing booleans for variants as discussed above, we build the right bootstrap class name string below. note how we always pass btn regardless
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
function Button({ children, primary, secondary, success, warning, danger, outline, rounded }) { const className = classnames('btn', { 'btn-primary': !outline && primary, 'btn-secondary': !outline && secondary, 'btn-success': !outline && success, 'btn-warning': !outline && warning, 'btn-danger': !outline && danger, 'btn-outline-primary': outline && primary, 'btn-outline-secondary': outline && secondary, 'btn-outline-success': outline && success, 'btn-outline-warning': outline && warning, 'btn-outline-danger': outline && danger, 'rounded-4': rounded }); return <button className={className}>{children}</button>; }
Advanced Techniques
- to allow developers to continue sending the traditional props like
onClick
,onMouseOver
to the underlying button element via our wrapper button component, we can do the following -1 2 3 4 5 6 7 8 9
function Button({ children, primary, secondary, success, warning, danger, outline, rounded, icon, ...props }) { // ... return ( <button className={className} {...props}> {icon && <i className={icon} />} {children} </button> ); }
- now, we can add props to our custom component like so -
<Button onClick={...}>
- additionally, to allow users to add their own class names to our custom button component, e.g. margins, paddings and so on, we can do the following -
- classnames receives an additional static input
- ensure that the output of classnames is passed into
className
after the{...props}
clause
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
// provide the custom class name by default const className = classnames('btn', props.className, {...}) return ( // className created by us overrides className part of props <button {...props} className={className}> {icon && <i className={icon} />} {children} </button> ); // example usage <Button className="mb-4" success rounded outline icon="bi bi-check-circle me-2"> Click Here! </Button>
Events
- “events” - be notified when users interact with our app
- we create an “event handler” or a “callback” which gets called when the event is performed, and pass it as a “prop” to the element whose events we want to listen to
- recall how we mentioned we use props to pass data from parents to children
- thats not entirely true, because “we can do the other way around by using event handlers”
- e.g. when we try to add an
onClick
prop to a button, the button i.e. the child essentially passes the event to the current component i.e. the parent
State
- as the contents of our “state” changes, react automatically “rerenders” the content on the screen
- rerendering essentially means running through the entire component once again
- the three parts of the use state line are -
- the current state
- the setter to update the state
- the default value of the state
- note - we should never update state directly i.e. never do
arr.push(newObj)
manually, as otherwise react would not be able to cause rerender- internally, react checks if the previous and current state are same. if they are, it does not cause a rerender
- by doing
arr.push
and then callingsetArr(newObj)
, we are making updates in place, and the state is still pointing to the same object - instead, we should do something like this -
setArr([...arr, newObj])
. this causes the state reference to point to an entirely different object - this then tells react to cause the rerender
- we need to be mindful of this when interacting with objects, arrays, etc, this is not an issue with primitives like strings, numbers, etc
- cheat sheet on how to make changes to state
- inserting an element at a specific position -
1 2 3 4 5
const addColorAtIndex = (newColor, index) => setColors([ ...colors.slice(0, index), newColor, ...colors.slice(index) ]);
- removing an element at a specific position - the callback passed to filter receives an index as well
1 2 3
const removeColorAtIndex = (indexToRemove) => setColors( colors.filter((color, index) => index !== indexToRemove) )
- modifying elements with a specific unique identifier -
1 2 3 4 5 6
const updateBookById = (id, title) => setBooks( books.map((book) => { if (book.id !== id) return book; return { ...book, title }; }) );
- inserting an element at a specific position -
- we cannot share data between sibling components directly, so involve their common parent in such cases
- note - rerendering the component also means rerendering of all of its children / its entire subtree
Handling Forms
- assume we have a “search bar” component in our app
- we want to ensure the triggering of some logic (e.g. calling an api) when we hit enter on the input
- however, default behavior on submit - makes a request to url.com?email=a@b.com,password=asdf
- so, we need to prevent the default behavior of forms
- additionally, we always use “controlled inputs” in react i.e. track the input values using state
- update the state by passing a callback for on change
- set the value of the input to be the same as this state
- what we are truly doing with “controlled inputs” - we are taking control over from the browser, and maintaining the state using react on our end
- this helps us do other things in the react world, like ensure rerenders on input (and effectively state change), etc. this way, features like form validations etc can work the right way
- in the example below, we use “controlled input” to manage the value in the search bar via state, and events for propagating the search term to the parent
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
function SearchBar({ onSubmit }) { const [searchTerm, setSearchTerm] = useState(""); const onFormSubmit = (e) => { e.preventDefault(); onSubmit(searchTerm); } return ( <div> <form onSubmit={onFormSubmit}> <div className="input-group"> <span className="input-group-text"> <i className="bi bi-search"></i> </span> <div className="form-floating"> <input type="text" className="form-control" id="search" name="search" placeholder="Search for Images" onChange={(e) => setSearchTerm(e.target.value)} value={searchTerm} /> <label htmlFor="search">Search for Images</label> </div> </div> </form> </div> ) }
Key Prop in Lists
- we need to pass the key prop when rendering lists, else react shows an error
- lists can allow adding or removing of elements, reordering of elements, etc
- now, the jsx we are trying to render, for e.g. using
list.map
has changed - but react would not entirely delete the old dom elements from the ui and recreate everything from scratch
- it would instead compare the old and new outputs of
list.map
using the key prop that we passed, and only make the necessary changes. e.g. it would not make any changes to the dom for elements that have the same output - assume we have to render a list, and are using the index of the element as the key
- now, if for e.g. we delete the fourth element, the list keys change from for e.g.
[0,1,2,3,4,5,6]
to[0,1,2,3,4,5]
- so, react will essentially think after comparing the two that the 7th element has been deleted, which is not right
- note - assume i render the same list twice by mapping the output differently. the two lists can use the same key, e.g. both can use
person.id
, since they are different lists and react will handle updating them separately
Context
- “props” - communication between a parent and its immediate child
- using context, we can share state across components without a direct link
- first, we create the context -
1
export const BookContext = createContext()
- then, we wrap the app using this context’s
Provider
- we can do this in for e.g. main.jsx so that all components can have access to this context
- notice how the “initial value” for this context needs to be passed to this provider as well
1 2 3 4 5 6 7
createRoot(document.getElementById('root')).render( <StrictMode> <BookContext.Provider value={5}> <App /> </BookContext.Provider> </StrictMode>, )
- issue - the value we are passing here is static. we typically want to pass pieces of state here that can change dynamically, and hence, we usually build another layer of wrapper around this context provider
- changing of state will cause this wrapper and hence all its children to rerender
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
export const BookContextProvider = ({ children }) => { const [books, setBooks] = useState([]) const fetchBooks = () => axios.get("http://localhost:8080/books") .then(({ data: books }) => setBooks(books)) return ( <BookContext.Provider value=(( books, fetchBooks ))> {children} </BookContext.Provider> ) } // .... createRoot(document.getElementById('root')).render( <StrictMode> <BookContextProvider> <App /> </BookContextProvider> </StrictMode>, )
- “component state” - state used by one or few components
- “application state” - state used by many different components
- we should try storing application state, and not component state inside context
- now, to access the values of this context, we make use of
useContext
in the consumers
Use Effect
- the function passed to “use effect” is called immediately “after” the “first render” (note - after)
- now, whether it is called “after” any “subsequent rerenders” depends on the second argument passed to it
- second argument is
[]
- only called after first render - second argument is missing - called after first render and after every subsequent rerender
- second argument is
[counter]
- called after first render and after every subsequent rerender if the counter variable has changed
Stale Variable Reference and Cleanup Function
- imagine we have an app like this -
- display the counter
- a button to increment the counter
- additionally, we attach an on click listener to the body, that logs the counter
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
function App() { const [counter, setCounter] = useState(0); useEffect(() => { document.body.addEventListener("click", () => { console.log(counter); }); }, []); return ( <div> <button onClick={() => setCounter(counter + 1)}>+ Increment</button> <div>Count: {counter}</div> </div> ); }
- result - while the counter on the dom displays the right value, the event listener keeps logging the initial value
- reason - whenever we change counter, essentially a “new variable” is created when the component rerenders
- note - i used to think the variable / reference stays the same, and we change the object it points to when mutating the state. but apparently, the variable itself is created afresh as well when the component rerenders
- so, the listener continues pointing to the old counter and keeps logging it, and our function inside use effect only ran after the “first render” as described here
- “eslint exhaustive deps” will help point this out to us, and we can thus fix it as follows -
1 2 3 4 5
useEffect(() => { document.body.addEventListener("click", () => { console.log(counter); }); }, [counter]);
- issue with this fix - a new listener is attached after every change
- after first click on button - 0 1 gets logged
- after second click on button - 0 1 2 gets logged
- etc…
- final fix - we can return a function from use effect for “cleanup”
1 2 3 4 5
useEffect(() => { const listener = () => console.log(counter); document.body.addEventListener("click", listener); return () => document.body.removeEventListener("click", listener); }, [counter]);
- recall how we saw that after every rerender, whats inside use effect gets run again. turns out that even before this, the cleanup function of the previous use effect gets run
- thus this way, the previous listener is removed before the new listener gets attached. the new listener now points to the new counter variable. hence, everything works as expected
Use Callback
- we saw in stale variable reference how this eslint check helped us
- however, blindly following it can cause issues sometimes, and we might need additional fixes. e.g. above, we had to add the “cleanup” function logic
- refer context for how the custom provider wrapper is set up. we are trying to call fetch books inside use effect for the first render of the component to hydrate the initial state of our app
1 2 3 4 5
const { fetchBooks } = useContext(BookContext) useEffect(() => { fetchBooks() }, [])
- eslint again complains as follows -
1 2
React Hook useEffect has a missing dependency: 'fetchBooks'. Either include it or remove the dependency array.eslintreact-hooks/exhaustive-deps
- we can fix it as follows -
1 2 3
useEffect(() => { fetchBooks() }, [fetchBooks])
- issue - we see that the network console is flooded with infinite requests
- reason -
- fetch books calls set books bts and therefore updates the books
- this thus causes the custom context provider and all its children to rerender
- now, look at the custom provider wrapper in context. this leads to the recreation of fetch books
- this means that fetch books changes, and causes what is inside the use effect to run again
- and thus, fetch books keeps getting called in an infinite loop
- so, we use “use callback” with an empty array
- after first render, it will return us the function we passed to it
- on any subsequent rerenders, it will return the same function object. while a new variable / reference called
fetchBooks
is being created every time, it continues pointing to the same object - it is entirely ignoring the fact that on every rerender, we are passing it a new function, and is just returning us what we had passed it on the first render
1 2 3 4 5
const fetchBooks = useCallback( () => axios.get("http://localhost:8080/books") .then(({ data: books }) => setBooks(books)), [] )
- now, we can specify some elements inside the array as well. if some of them have changed since the last render, use callback will return us the new updated first argument i.e. the new version of the function object
Updating State Caveat
- assume we have an accordion component, that expands an option when we click on it, and collapses when we click on it again
- we can obtain the reference to the first accordion item and simulate a click on it via the console like so -
1
document.querySelector(".accordion-button").click()
- if it was expanded, it will collapse and vice versa
- what if we do it twice in quick succession? simulate it like so -
1
document.querySelector(".accordion-button").click(); document.querySelector(".accordion-button").click();
- expectation - nothing should happen - it should stay collapsed if it was initially collapsed, else stay expanded if it was initially expanded
- what happens - it collapses if it was expanded and vice versa
- why - react does not update the state immediately, it “batches” the updates
- so, for both clicks,
expandedIndex
evaluates to index if it was expanded /null
if it was collapsed - hence, the state of the accordion simply toggles once
- solution - when our state updates rely on the previous value of the state, we should instead consider updating our state like so -
1 2 3 4 5 6 7 8
const onClick = (index) => setExpandedIndex((expandedIndex) => { if (expandedIndex === index) { return null; } else { return index; } });
- the callback accepts the old value of the state, and returns the new computed value of the state
- when we pass a callback to the state setter, react “guarantees” to pass the most up to date value of the state
- note - people do not usually solve this problem, as the entire thing happened because we were trying to simulate events in quick succession, which does not typically happen
Event Bubbling and Event Capturing
- note - this is mostly a javascript thing, has nothing to do with react
- “event capturing” - move from parent to child
- “event bubbling” - move from child to parent
- every time any interaction, like a click happens -
- first, javascript invokes all the “capturing related event handlers” from the document to the element
- then, javascript invokes all the “bubbling related event handlers” from the element to the document
- assume we have setup our html as follows -
1 2 3 4 5 6
<div id="grandparent"> <div id="parent"> <div id="child"> </div> </div> </div>
- assume we have the following inside the script tag. if we click the child, the output is of bubbling. this is because, by default the third argument is for “use capture”, and is false by default
1 2 3 4 5 6 7
document.querySelector('#grandparent').addEventListener('click', () => console.log('grandpa was clicked')); document.querySelector('#parent').addEventListener('click', () => console.log('papa was clicked')); document.querySelector('#child').addEventListener('click', () => console.log('son was clicked')); // son was clicked // papa was clicked // grandpa was clicked
- assume we pass true for use capture for all three. the output changes as follows -
1 2 3 4 5 6 7
document.querySelector('#grandparent').addEventListener('click', () => console.log('grandpa was clicked'), true); document.querySelector('#parent').addEventListener('click', () => console.log('papa was clicked'), true); document.querySelector('#child').addEventListener('click', () => console.log('son was clicked'), true); // grandpa was clicked // papa was clicked // son was clicked
- a mix and match example -
1 2 3 4 5 6 7
document.querySelector('#grandparent').addEventListener('click', () => console.log('grandpa was clicked'), true); document.querySelector('#parent').addEventListener('click', () => console.log('papa was clicked'), false); document.querySelector('#child').addEventListener('click', () => console.log('son was clicked'), true); // grandpa was clicked // son was clicked // papa was clicked
- finally, we can stop the propagation of the event i.e. stop it from going to the child during capturing / stop it from going all the way to the document during bubbling by using
e.stopPropagation()
, recall that e is the first argument to the listeners
useRef
- assume we have a working custom dropdown component
- issue - we would like to close the dropdown even if the user clicks somewhere outside the dropdown
- otherwise the dropdown stays in the open state, thus cluttering up the ui
- first, we would like to listen for all the click events, so we attach the click event listener to the document itself
- my understanding - what is “document” - it is the parent of “body” of sorts. we could have also done
document.body.addEventListener
i guess
- my understanding - what is “document” - it is the parent of “body” of sorts. we could have also done
- “useRef” - allows a component to obtain the reference to a “dom element” that it creates
- e.g. in this case using use ref, we can determine where the click event originated from
- we access it using the
.current
property on the reference created by the hook - note - use ref can also be used for other purposes as well, not discussed here
- finally, we can set up the event listener once when the component is rendered, and clean up the listener when the component goes away
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// ....
useEffect(() => {
const listener = (e) => {
if (ref.current.contains(e.target)) {
console.log('click was inside dropdown');
} else {
setIsOpen(false);
console.log('click was outside dropdown');
}
};
document.addEventListener('click', listener);
return () => document.removeEventListener('click', listener);
});
// ....
return (
<div className="dropdown" style=(( display: 'inline-block' )) ref={ref}>
<button
className={classnames('btn btn-secondary', {
show: isOpen,
})}
type="button"
>
{/* ... */}
</div>
)
Custom Navigation in React
- note - this can be a good question to practice on local without referring this solution
- we always send the index.html and bundle.js file in react, unlike traditional applications where there is a different html page per route
- now, based on the url in the address bar, react dynamically decides what component to show
- if the address bar has /posts, react will show the “post list” component, and make an api call to fetch posts
- if the address bar has /images, react will show the “image list” component, and make an api call to fetch images
- there might be some components like the navigation bar, that react always shows, irrespective of the route
- to obtain the current path we are on, use
window.location.pathname
- we use context to be able to easily use navigation functionality across our app
1 2 3 4 5 6 7 8 9 10 11
const NavigationContext = createContext(); export const NavigationContextProvider = ({ children }) => { const [currentPath, setCurrentPath] = useState(window.location.pathname); return ( <NavigationContext.Provider value=(( currentPath ))> {children} </NavigationContext.Provider> ); };
- to change the address bar url dynamically -
window.location = '/docs/5.3/components/button-group'
- causes a full page refreshwindow.history.pushState({}, '', '/docs/5.3/components/button-group')
- does not cause the full page refresh and just updates the address bar
- think of navigation like a “stack” - elements get pushed onto a stack when we manually change the url / use the forward button, and get popped off the stack when we use the back button
1 2 3 4 5 6 7 8 9 10 11 12
const navigate = useCallback((to) => { window.history.pushState({}, '', to); setCurrentPath(to); }, []); return ( <NavigationContext.Provider value= > {children} </NavigationContext.Provider> );
- how to avoid full page refresh when using links - use a wrapper component around the anchor tag, and we prevent the default behavior, and introduce our custom behavior
1 2 3 4 5 6 7 8 9 10 11 12 13 14
function Link({ children, to }) { const { navigate } = useContext(NavigationContext); const onClick = (e) => { e.preventDefault(); navigate(to); }; return ( <a href={to} onClick={onClick}> {children} </a> ); }
- how back and forward buttons work -
- they cause full page reloads when we update the url manually and press enter
- they just update the address bar when we use techniques like
pushState
- whenever forward or back buttons are used -
popstate
event is emitted. we need to listen for these events to update the current path piece of state1 2 3 4 5 6 7 8 9 10 11 12
export const NavigationContextProvider = ({ children }) => { const [currentPath, setCurrentPath] = useState(window.location.pathname); useEffect(() => { const listener = () => setCurrentPath(window.location.pathname); window.addEventListener('popstate', listener); return () => window.removeEventListener('popstate', listener); }, []); {/* ... */}
- so basically, we need to sync our piece of current path state with changes in the url, and there are two ways of doing this -
- back and forward buttons - browser history stack is updated automatically. we just need to listen for the events to update our state
- clicking on links - we created a custom link component. in this case, we need to take care of updating both the browser history stack and the state
Portals
- portals are useful to for e.g. to create “modals”
- we want an overlay stretching across the entire screen, and the modal to be positioned at the center
- we can use
position: absolute
, but it only works if none of the parents have a position other than the default of “static” - so, we instead use portals
- it will take all the html and put it inside a div which is for e.g. a sibling of the “root” div
- this ensures that there are no parents of the modal component with styled position
- first, introduce this inside index.html -
1
<div id="modal-container"></div>
- next, create the component like follows -
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
function ModalComponent({ close, children, title, actions }) { return ReactDOM.createPortal( <div> <div className="modal-backdrop fade show" /> <div className="modal-header"> <h5 className="modal-title">{title}</h5> <button type="button" className="btn-close" onClick={close} /> </div> <div className="modal-body">{children}</div> <div className="modal-footer">{actions}</div> </div> </div>, document.querySelector('#modal-container'), ); }
- finally, we can render the component as follows -
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
function ModalPage() { const [isOpen, setIsOpen] = useState(false); const open = () => setIsOpen(true); const close = () => setIsOpen(false); return ( <div> <Button primary onClick={open}> Open Modal </Button> {isOpen && ( <ModalComponent isOpen={isOpen} close={close} title={'Example of a Modal'} actions={<Button primary onClick={close}>I Accept</Button>} > <p>lorem ipsum</p> </ModalComponent> )} </div> ); }
Fragments
- imagine our component receives a config prop, which is an array
- each element has properties
renderHeader
andlabel
among other things1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
const config = [ { label: 'Fruits', render: (fruit) => fruit.name, renderHeader: () => <th>Why so Serious?</th> }, { label: 'Color', render: (fruit) => <button className={`disabled btn btn-${fruit.color}`} /> }, { label: 'Score', render: (fruit) => fruit.score, }, ];
- now, our component would render the return value of
renderHeader
if present, else wrap the label aroundth
1 2 3 4
{config.map(({ label, renderHeader }) => { if (renderHeader) return renderHeader(); return <th key={label}>{label}</th>; })}
- issue - we get the error
Warning: Each child in a list should have a unique "key" prop.
- this is because we cannot expect developers to provide the key prop themselves as part of the config object
- we do not want to add a random div around renderHeader, as that will mess up our html structure
- solution - “fragments”
1
if (renderHeader) return <Fragment key={label}>{renderHeader()}</Fragment>;
useReducer
- when to use “use reducer” instead of “use state” -
- whenever we have multiple pieces of state
- the different pieces of state are somehow related to each other
- the next value of state depends on its previous value
- just like in use state, changing state in use reducer makes the component rerender
- the reducer accepts two arguments - the current “state” and “action”. it computes and returns the new state
- it should be like a “pure function” - it does not do things like making external requests, modifying the state directly, etc
- community convention - the action object should have a “type” and “state”
- we typically use constants to avoid typos when using action types
- usually, put more logic into the reducers and keep the application simple - e.g. have different actions for increment and decrement, instead of having a generic set
- this way, we also have precise controls over how users can modify state
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const ActionTypes = {
INCREMENT: 'increment',
DECREMENT: 'decrement',
};
const reducer = (state, action) => {
switch (action.type) {
case ActionTypes.INCREMENT:
return {
...state,
count: state.count + 1,
};
case ActionTypes.DECREMENT:
return {
...state,
count: state.count - 1,
};
default:
return state;
}
};
function CounterPage({ initialCount }) {
const [state, dispatch] = useReducer(reducer, {
count: initialCount,
value: 0,
});
const increment = () => dispatch({ type: ActionTypes.INCREMENT });
const decrement = () => dispatch({ type: ActionTypes.DECREMENT });
{/* .... */}
}
Immer
- allows us to directly modify the state inside our reducers. bts, it takes care of creating the new object for us
- note - we still need to add return statements to the switch statements, because they are “fall through” i.e. otherwise, the cases afterwards would be executed as well
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const reducer = (state, action) => {
switch (action.type) {
case ActionTypes.INCREMENT:
state.count = state.count + 1;
return;
case ActionTypes.DECREMENT:
state.count = state.count - 1;
return;
default:
return state;
}
};
function CounterPage({ initialCount }) {
const [state, dispatch] = useReducer(produce(reducer), {
count: initialCount,
value: 0,
});
{/* .... */}
}
Redux Toolkit
- the flow stays the same as we saw in use reducer -
- an “action” object is sent to “dispatch”
- this inturn invokes the “reducer”
- the reducer computes and returns the new state
- this results in the rerendering of the component
- with redux, the state and components are entirely “decoupled”
- we have a “central place” for managing state, and the components can interact with it by plucking the relevant bits of state / via actions
- we use a library called react-redux, since redux is not inherently built just for react
- apparently, react-redux uses use context under the hood
- we typically have multiple reducers for managing multiple pieces of state, unlike we saw in use reducer. this helps us achieve “separation of concerns”
- “redux toolkit” (rtk) - simplifies working with redux a lot more
- we typically have one “store” per application - on really large projects, we might have more than one
1 2 3 4 5
const store = configureStore({ reducer: { songs: songsSlice.reducer, }, });
- we wire the store with react like so -
1 2 3 4 5 6 7 8 9 10 11
import { store } from "./store"; import { Provider } from "react-redux"; const rootElement = document.getElementById("root"); const root = createRoot(rootElement); root.render( <Provider store={store}> <App /> </Provider> );
- we do not typically interact with the store directly. but we can if needed for debugging purpose, using the following -
1 2 3 4 5 6 7
store.dispatch({ type: "songs/addSong", payload: "Shake it off!" }); console.log(store.getState()); // { // "songs": [ // "Shake it off!" // ] // }
- the songs piece of state is available via “songs”, because we specified the songs reducer under the songs key when configuring the store
- example of creating a “slice” -
1 2 3 4 5 6 7 8 9
const songsSlice = createSlice({ name: "songs", initialState: [], reducers: { addSong(state, action) { state.push(action.payload); }, }, });
- inside slices, the “initial state” property - can be an array, object, etc anything based on our use case
- a bit of terminology - notice how the different “switch statement” equivalents are called “reducers” in redux toolkit - it is like we are combining several mini reducers into one giant reducer that represents the “songs” piece of state
- we are automatically making use of “immer”, thus allowing us to mutate state
- understand how these different mini reducers do not receive the entire state, they only receive the songs piece of state, which is why we are directly calling
push
on it directly. this way, our mini reducers only have visibility into the state that the actual combined reducer is responsible for - the above point is important because for e.g. “use selector” that we use in components receives all of the state
- type of action = “name” we use when creating the slice + “/” + name of the mini reducer, e.g. addSong. this is what we see in
store.dispatch
- however, redux toolkit also takes create of generating “action creators” for us automatically, so we just have to take care of passing the payload, and it generates that action object automatically for us -
1 2 3
const dispatch = useDispatch(); // ... const handleSongAdd = (song) => dispatch(songsSlice.actions.addSong(song));
- finally, we can access state inside of our component using “use selector”. it receives the entire state, and we pluck out the pieces of state that we care about
1
const songPlaylist = useSelector((state) => state.songs);
- note - assume we want to implement a reset functionality. issue - if we want to add / remove songs, we can easily mutate the array, but we cannot clear it as easily. e.g.
songs = []
would not work. in such cases, we can instead return the new state, and thats what redux toolkit would look at instead1 2 3
reset(state) { return [] }
- now assume that on clicking reset, we want to reset the state for both movies and songs piece of state
- approach 1 (naive) - we make two separate dispatch calls. this is not the “redux” way of doing things - we should have to dispatch just one event, and both the reducers / slices should listen for this event
1 2 3 4 5 6
const dispatch = useDispatch(); const handleResetClick = () => { dispatch(songsSlice.actions.reset()); dispatch(moviesSlice.actions.reset()); };
- little bit background - recall that the action types generated is a concatenation of -
- “name” we specify when creating a slice
- /
- name of the mini reducer
- all actions reach all slices, e.g. songs/addSong would reach the movies slice as well
- it is just that the movie slice will ignore this, since it has no corresponding user to handle it
- so, option 2 - have movie slice listen to song slice’s actions
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
const handleResetClick = () => dispatch(songsSlice.actions.reset()); // ... const songsSlice = createSlice({ name: "songs", initialState: [], reducers: { // ... reset(state, action) { return []; }, }, }); // ... const moviesSlice = createSlice({ name: "movies", initialState: [], reducers: { // ... }, extraReducers(builder) { builder.addCase("songs/reset", (state, action) => { return []; }); }, });
- look at how we make use of the “extra reducers” property inside of movies slice - we can chain as many additional reducers as we want here, that do not follow the usual convention of “action type” for a slice
- finally, we can prevent hardcoding of the action type string. the “to string” of actions inside redux toolkit is the action type!
1 2 3 4 5 6 7 8 9 10 11 12
const moviesSlice = createSlice({ name: "movies", initialState: [], reducers: { // ... }, extraReducers(builder) { builder.addCase(songsSlice.actions.reset, (state, action) => { return []; }); }, });
- downside - song slice is now somewhat dependent on movie slice. i personally think this is still useful in some cases depending on “semantics”, but not in this case
- option 3 - create and register an additional action using “create action”
1 2 3 4 5 6
import { createAction } from "@reduxjs/toolkit"; export const reset = createAction("app/resetPlaylist"); console.log(reset("some payload")); // { "type": "app/resetPlaylist", "payload": "some payload" }
- understand that we are basically providing the action type, and it generates a function for us. whatever we pass this function becomes the payload
- now, we use the same “to string” hack to avoid hardcoding the type in “extra reducers” -
1 2 3 4 5 6 7
// ... extraReducers(builder) { builder.addCase(reset, (state, action) => { return []; }); },
- and we can use this manually generated action like so -
1
const handleResetClick = () => dispatch(reset());
- adding redux to the project -
npm i @reduxjs/toolkit react-redux
- tip - “derived state” - do not store state that can be derived separately. e.g. total cost can be derived by summing up the costs of individual items
- tip - do not change models to fit ui needs. e.g. assume we have a piece of state representing a car. do not add a property like
highlight: true
to it. find out other ways to handle this
Number Input
- input type of number -
<input type="number" />
- actually uses string, i.e. if we useonChange
on this input,e.target.value
will actually hold “123” string and not 123 the number. solution - - when changing state - use
parseInt
/parseFloat
when parsing events. since it can result in values likeNaN
, use|| 0
- when setting value - because of above, we would not be able to clear the input entirely, it will always at least show 0. when we try to clear the input, the change event would hold “”, and the value computed would be 0 and hence, 0 is what is shown ultimately. fix - set value to an empty string in case the value is 0 using
|| ""
- below is an example of number input, using redux toolkit
1
2
3
4
5
6
7
8
9
10
11
12
13
14
import carFormSlice from "../store/slice/carFormSlice";
const { changeName, changeCost } = carFormSlice.actions;
// ...
const onCostChange = (e) => dispatch(changeCost(parseFloat(e.target.value) || 0));
// ...
<Form.Control
type="number"
placeholder="1500"
value={cost || ""}
onChange={onCostChange}
/>
Reselect
- i was doing the below -
1 2 3 4 5
const cars = useSelector(({ cars: { carList, searchTerm } }) => carList.filter((car) => car.name.toLowerCase().includes(searchTerm.toLowerCase()) ) );
- because of this, i saw the following warning in console -
1 2 3 4
CarList.jsx:11 Selector unknown returned a different result when called with the same parameters. This can lead to unnecessary rerenders. Selectors that return a new reference (such as an object or an array) should be memoized: https://redux.js.org/usage/deriving-data-selectors#optimizing-selectors-with-memoization
- whatever is inside
useSelector
is rerun after every action is dispatched - now, based on whether the value it returns is updated or not, the component is rerendered
- till now, we usually grabbed the state value as is -
useSelector((state) => state.cars.carList)
- but what if we want to perform some computation on this, e.g. map to a different output? e.g.
useSelector((state) => state.cars.carList.map(....))
- issue 1 - map will always return a new array - this way, the output always changes, thus rerendering our component every time
- issue 2 - what if map contains some complex expensive logic? recall that whatever is inside
useSelector
gets rerun after dispatch of every action - because of this, libraries like “reselect” exist
- reselect has “input selectors” and “output selectors”. the output selector gets run only if any of the input selectors return a different value. this solves issue 2. if none of the inputs change, the component does not rerender, thus solving issue 1
- so, general pattern - “input selectors” extract the state, “output selectors” perform the computation
1 2 3 4 5 6 7 8 9 10 11 12
const filteredCarsSelector = createSelector( [(state) => state.cars.carList, (state) => state.cars.searchTerm], (carList, searchTerm) => { return carList.filter((car) => car.name.toLowerCase().includes(searchTerm.toLowerCase()) ); } ); // ... const cars = useSelector(filteredCarsSelector);
Async API
- context around the app - we show a list of users using an accordion
- each user has a set of albums. the albums for a user are again displayed using an accordion
- each album has a set of photos
- strategy - “lazy fetching” - we just fetch the list of users when the app loads
- when we expand the accordion for a user, we fetch the list of albums for the user and display it
- when we expand the accordion for an album, we fetch the list of photos in the album and display it
- we use async thunks for handling users and redux toolkit query for handling albums and photos
Async Thunks
- we track three pieces of state - loading, error and data
- the initial state looks like this -
1 2 3 4 5
initialState: { data: [], isLoading: false, error: null, },
- when we make the api call, we dispatch an action. at the back of this action, the loading flag is flipped to true
- now, depending on the http response from the server, we dispatch another action. at the back of this, we either set the data or the error piece of state, and flip the loading flag back to false
- first, we create the thunk. the first argument is a “type prefix”, which gets prepended to the action types of pending etc that get dispatched
1 2 3 4 5 6 7
import { createAsyncThunk } from "@reduxjs/toolkit"; import axios from "axios"; export const fetchUsersThunk = createAsyncThunk("users/fetch", async () => { const response = await axios.get("http://localhost:3000/users"); return response.data; });
- now, redux toolkit is already exposing three properties on thunks created using it - “pending”, “fulfilled” and “rejected”
- these are as action types when adding cases to to the “extra reducers” property
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
extraReducers: (builder) => { builder.addCase(fetchUsersThunk.pending, (state, action) => { state.isLoading = true; }); builder.addCase(fetchUsersThunk.rejected, (state, action) => { state.isLoading = false; state.error = action.payload; }); builder.addCase(fetchUsersThunk.fulfilled, (state, action) => { state.isLoading = false; state.error = action.error; }); },
- for fulfilled - whatever we return from inside the callback becomes the action’s “payload”
- for rejected - redux toolkit adds it to the “error” property (important - error property, not payload). hence, we do
state.error = action.error
- finally, we can use this thunk by simply calling it and sending it to dispatch, just like we call normal “action creators”
1 2 3 4 5
const dispatch = useDispatch(); useEffect(() => { dispatch(fetchUsersThunk()); }, [dispatch]);
- a look inside the redux dev tools -
- note - i saw two instead of 1 network request being made. this is due to the strict mode wrapper, which causes extra rerenders. commenting it out inside index.jsx solves the issue
- “fine grained loading state” - we introduced a loading piece of state for when users are being fetched, to display a “skeleton” list component
- however, we might need a separate piece of state for when users are being created, to for e.g. show a spinner on the create user button
- for handling deleting of users, we might want to track it separately yet again. this time though, we might need a separate piece of loading state for each user
- maintaining so much state can become complex quickly, and redux toolkit query solves issues like these for us
Redux Toolkit Query
- “redux toolkit” already ships with “redux toolkit query”
- “query” vs “mutation” - queries are used for fetching data while mutations are used for modifying data
- redux toolkit query uses fetch bts
- “reducer path” - the path used inside the store
- “base query” - basic configuration like base url used in api calls
- “endpoints” - we configure the different endpoints using the “builder”. e.g. below, we configure it to make requests for http://localhost:3000/albums?userId=2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
const albumApi = createApi({ reducerPath: "albums", baseQuery: fetchBaseQuery({ baseUrl: "http://localhost:3000", }), endpoints(builder) { return { fetchAlbums: builder.query({ query: (user) => { return { url: "/albums", params: { userId: user.id }, method: "GET" }; }, }), }; }, });
- note - remember to import
createApi
from@reduxjs/toolkit/query/react
and not@reduxjs/toolkit/query
for the react hooks to be generated automatically for us - we need to perform the following steps to connect this “api” to the store
- add the “mini reducer” to the reducer property of store
- add to the list of “middlewares”
- provide the store’s dispatch to redux toolkit query
1 2 3 4 5 6 7 8 9 10 11
export const store = configureStore({ reducer: { [albumApi.reducerPath]: albumApi.reducer, }, middleware(getDefaultMiddleware) { return getDefaultMiddleware().concat(albumApi.middleware); }, }); setupListeners(store.dispatch);
- the name we give to the properties when configuring the api will determine the hooks that are automatically generated for us, e.g. because we provided the key name as
fetchAlbums
and configured it using a “query”, the following hook name is generated for us automatically1 2 3 4 5
const { useFetchAlbumsQuery } = albumApi; function AlbumList({ user }) { const { data, error, isLoading } = useFetchAlbumsQuery(user); // ...
- note how we pass the user argument to it, because of how we had configured
fetchAlbums
isLoading
is true only the first time around, whileisFetching
is true every time data is being fetched- we can use the
refetch
function to force a refetch of the data - when creating a mutation, not much changes inside the
createApi
code, it is similar to the query1 2 3 4 5 6 7 8 9 10 11 12
createAlbum: builder.mutation({ query: (user) => { return { url: "/albums", method: "POST", body: { userId: user.id, title: faker.commerce.productName(), }, }; }, }),
- however, the mutation hook and query hook are a little different
- in case of query hooks, the query is fired whenever the component is mounted into the dom
- in case of mutation hooks, the mutation is fired on an action like clicking of a button. so, it is not fired off immediately
1 2 3
const [createAlbumMutation, { isLoading }] = useCreateAlbumMutation(user); const createAlbum = () => createAlbumMutation(user);
- an important note - assume we add the hook with the same parameters multiple times. redux toolkit query detects this automatically, and it only makes the network call once instead of multiple times. notice the key under albums.queries dictionary. it represents the query and the arguments being passed to it. the same logic applies to mutations as well
- issue - creating an album does not automatically update the list of albums that we display
- solution 1 - use the response from the create api call and use it to update the albums piece of state
- downside - if using pagination, how do we figure out if this record should be added to current page or not?
- solution 2 - ignore the response, and re-trigger the call to fetch all the albums and then re-update the state
- additionally, making a fresh call helps hydrate the state with updates made by other users
- redux toolkit query themselves promote option 2 as well, and makes it easy to implement this
- we can add “tags” to certain “queries”, and mark certain “mutations” to “invalidate” these “tags”
- after the mutation is run, it marks the queries with those tags as stale, thus causing them to make the network call again
- so, the solution now looks like this - look at
provideTags
andinvalidatesTags
specifically1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
fetchAlbums: builder.query({ providesTags: ["albums"], query: (user) => { return { // ... }; }, }), createAlbum: builder.mutation({ invalidatesTags: ["albums"], query: (user) => { return { // ... }; }, }),
- now, we have a new issue -
- first, a network call for users is made (i see two because of strict mode probably)
- then, say we expand the accordion for three users one by one. fresh network calls for all three users are made
- say we add an album to the first user
- however, notice how the refetch is performed for all three users. this is inefficient
- so, we need to make our refetching more “fine grained”
providesTags
can instead be a function. the first argument is the result from the fetch call, the second argument is the error, and the final argument is the “argument” i.e. whatever we pass the hook when using it1 2 3 4 5
const { data, isLoading } = useFetchAlbumsQuery(user); // ... providesTags: (result, error, user) => [ { type: "albums", id: user.id }, ],
invalidateTags
works in the similar way, except that instead of passing the user to the hook, we were calling the mutation function using the user, and that is what gets passed as the third argument toinvalidateTags
as well1 2 3 4 5 6 7
const [createAlbumMutation, { isLoading }] = useCreateAlbumMutation(); const createAlbum = () => createAlbumMutation(user); // ... const createAlbum = () => createAlbumMutation(user); invalidatesTags: (result, error, user) => [ { type: "albums", id: user.id }, ],
- note to self - the “id” property we return from both
invalidateTags
/providesTags
is not adhoc - i tried using “userId” initially, but that did not work - below is the snippet for handling deleting of albums. note - when creating or fetching albums, we needed access to a user. we do not need this when deleting an album though. so, the argument only accepts an album
1 2 3 4 5 6 7
deleteAlbum: builder.mutation({ invalidatesTags: // ??? query: (album) => ({ url: `/albums/${album.id}`, method: "DELETE", }), }),
- how to do cache invalidation now, since id should be user’s id? album object already the user id property on it -
1 2 3
invalidatesTags: (result, error, album) => [ { type: "albums", id: album.userId }, ],
- we got lucky that our albums had the user id property on it. what if it did not have that property?
- option 1 (the makeshift way) - pass in both the user and album to the mutation. notice how
invalidateTags
just extracts the user from the argument, whilequery
just extracts the album from the argument1 2 3 4 5 6 7 8 9 10 11 12 13
const [deleteAlbum, { isLoading }] = useDeleteAlbumMutation(); // ... const onDelete = (e) => deleteAlbum({ user, album }); // ... deleteAlbum: builder.mutation({ invalidatesTags: (result, error, { user }) => [ { type: "albums", id: user.id }, ], query: ({ album }) => ({ url: `/albums/${album.id}`, method: "DELETE", }), }),
- issue with the solution above - we are now passing both user and album for deleting an album. this breaks the semantics of our application
- we should not change the arguments for the mutation function like this, just to get the caching to work
- option 2 (the right approach) - change the
providesTags
for fetching albums as below. it returns an array of tags, one for each album and one separate tag for user. this way, this query can be invalidated using either the user or the album1 2 3 4 5 6 7 8 9
providesTags: (result, error, user) => { const albumTags = result.map((album) => ({ type: "album", id: album.id, })); const userTag = { type: "user", id: user.id }; return [...albumTags, userTag]; },
- invalidating the query using the user tag when creating an album -
1 2 3
invalidatesTags: (result, error, user) => [ { type: "user", id: user.id }, ],
- invalidating the query using an album tag when deleting an album -
1 2 3
invalidatesTags: (result, error, album) => [ { type: "album", id: album.id }, ],
- final note to self - it might be worth using
isFetching
instead ofisLoading
when using invalidations. otherwise, we will not see the spinner when fresh network calls are made due to invalidations, we will only the spinner the first time around
Typescript
- helps catch errors during development
- it also provides an implicit way of documentation for us
- note - typescript compiles down to javascript bts before getting executed in the browser
- e.g. we can enforce that the correct props and with the correct types are provided to components when using typescript
- we can visit the typescript playground here
- “type annotation” - helps describe the type of data flowing through the application
- e.g. below, we see an error for 40 since it is not a string -
1
const colors: string[] = ['red', 'green', 20]
- we can apply type annotations to functions as well -
1 2 3
function add(a: number, b: number): number { return a + b; }
- “type inference” - we can skip applying type annotations, and let typescript “guess” the type. e.g. when assigning variables at the same spot, when returning values, etc
1 2 3 4 5 6 7 8 9 10
// ts knows that this is a string const ocean = 'pacific' // ts knows that a number is returned function add(a: number, b: number) { return a + b; } console.log(ocean) console.log(add(1, 2))
- to handle objects when in typescript, we can use “interfaces”. notice how we can use it just like usual base types like string, number, etc
1 2 3 4 5 6 7 8 9 10 11
interface Car { model: string; year: number; make: string; } function formatCar(car: Car) { return `(model: ${car.model}, year: ${car.year}, make: ${car.make})` } console.log(formatCar({ model: 'Mustang', year: 1999, make: 'Ford' }))
- “function types” - we need to provide types of arguments and return type. note -
void
means that the function does not return anything1 2 3 4 5 6
interface Car { year: number; model: string; setYear: (year: number) => void; toString: () => string; }
- note - argument name in typescript need not match the actual argument names. notice the definition of
setYear
-1 2 3 4 5 6 7 8 9 10
const car: Car = { year: 1999, model: 'mustang', setYear(x) { this.year = x }, toString() { return `${this.model} (${this.year})` } }
- “extending interfaces” - can help with code reuse
1 2 3 4 5 6 7 8 9 10
interface ButtonProps { label: string; onClick: () => void; } interface IconButtonProps { label: string; onClick: () => void; icon: string; }
- we can modify
IconButtonProps
to as follows -1 2 3
interface IconButtonProps extends ButtonProps { icon: string; }
- “type unions” - e.g. when a function can accept arguments that can be of several different types -
1 2
function logOutput(value: string | number) { }
- “type guards” - if it is a number, we would like to round it off and print it. if it is a string, we would like to uppercase it and then print it
value.toUpperCase()
by itself will fail with the following error -1 2
Property 'toUpperCase' does not exist on type 'string | number'. Property 'toUpperCase' does not exist on type 'number'.
- so, we can use
typeof
. note -typeof
is a javascript feature, and is not from typescript. this way, we are “narrowing” the type, and typescript knows that if the check inside if passes, the value inside if will be a string. the same thing applies to the else part below - typescript knows that inside else, the type of value is a number. without the condition, the round function too would have failed1 2 3 4 5 6 7
function logOutput(value: string | number) { if (typeof value === 'string') { console.log(value.toUpperCase()) } else { console.log(Math.round(value)) } }
- both
typeof []
andtypeof {}
return “object”. so, we can useArray.isArray([])
instead. look at the convoluted example below -1 2 3 4 5 6 7 8 9 10
interface Image { src: string; alt: string; } function logOutput(value: Image | string[]) { if (typeof value === 'object' && !Array.isArray(value)) { console.log(value.src) // typescript knows value is of type Image } }
- again
typeof
will fail if for e.g. we are dealing with two different kinds of interfaces, sayUser
andImage
. fix - again javascript has an operator calledin
, which checks whether or not a property is present inside of an object. below, typescript will automatically be able to tell that if the if check passes, the value is of typeUser
1 2 3 4 5 6 7 8 9 10 11 12 13
interface Image { src: string; } interface User { username: string; } function logOutput(value: Image | User) { if ('username' in value) { console.log(value.username) } }
- “type predicate” - we can use functions to do this type checking for us. these are called “type predicates” inside of typescript. the key here is the return type. how typescript reads it - if this function returns true, the value is of type user -
1 2 3 4 5 6 7 8 9
function isUser(value: Image | User): value is User { return 'username' in value; } function logOutput(value: Image | User) { if (isUser(value)) { value.username } }
- “optional properties” - properties can or cannot be present. without the username property, i will get an error, but omitting the profile property is fine
1 2 3 4 5 6 7 8 9 10 11 12
interface UserProfile { likes: number; } interface User { username: string; profile?: UserProfile; } const user: User = { username: 'shameek' }
- now even if we provide the profile property, accessing the likes property inside of it gives the error “‘user.profile’ is possibly ‘undefined’.”
1 2 3 4 5 6 7 8
const user: User = { username: 'shameek', profile: { likes: 10 } } user.profile.likes
- we can overcome this using two ways -
- option 1 - if the if condition passes, we can be sure that the profile is not undefined, and hence we can access its properties -
1 2 3
if (user.profile) { console.log(user.profile.likes) }
- option 2 - by placing a question mark “after” the profile. if profile is undefined, the rest of the parts are not executed and a final value of undefined is returned. else, the value of likes is returned
1
user.profile?.likes
- option 1 - if the if condition passes, we can be sure that the profile is not undefined, and hence we can access its properties -
- “any” - using this type tells typescript to ignore type assertions around this variable. e.g. the code below will not throw any kind of errors -
1 2
const book: any = "" book.aksk.sksk.slksll
- we should avoid using this where possible, and linters might throw error messages as well if we use this
- sometimes, some libraries might return these types. we can use “type assertion” using “as” to forcibly tell react the type of this variable. e.g. fetch will return type of any, and we can use “type assertion” to declare the type of response -
1 2
const response = await fetch(`api.com/users/${isbn}`) const book = await response.json() as Book;
- “unknown” is the stricter version of “any” - we cannot use any properties on a variable that is unknown, unless we use techniques like “type predicates”
- try running the below example in the playground. we will notice that without the three checks we have inside the if, we would get errors on the
as Book
type assertion1 2 3 4 5 6 7 8 9 10 11 12
const fetchBook = async (isbn: string) => { const response = await fetch(`api.com/users/${isbn}`) const book: unknown = await response.json(); // book.title - gives an error if (book && typeof book === 'object' && 'title' in book) { return book as Book; } throw new Error('an unexpected error ocurred') }
- with the above code, typescript can infer automatically that the return type of this function is
Promise<Book>
- “type aliases” - e.g. instead of rewriting the union types everywhere, we can use type aliases defined in one place.
1 2 3 4 5
type LoggableValue = string | number | string[]; function LogValue(value: LoggableValue) { // ... }
- we can replace interfaces with type aliases, but unlike interfaces, they cannot be extended. so, we should only use them when we want to for e.g. “compute a new type” etc
1 2 3 4
type User = { name: string; age: number; }
Generics
- e.g. we want to make a function that accepts a value, wraps it in an array and returns it
- issue - how to use it for both number and string type? e.g. below, type of value is (string | number)[], which is not what we wanted
1 2 3 4 5
function wrapInAnArray(value: string | number): (string | number)[] { return [value] } const value = wrapInAnArray("shameek")
- generics - “arguments to customize the types” inside of our function. note how we are passing the it using angular brackets, just like we pass function parameters
1 2 3 4 5
function wrapInAnArray<T>(value: T): (T)[] { return [value] } const value = wrapInAnArray<string>("shameek")
- we can omit the generic argument, and it is automatically detected using “type inference”. similarly, we can also omit the return type as well -
1 2 3 4 5
function wrapInAnArray<T>(value: T): (T)[] { return [value] } const value = wrapInAnArray("shameek")
- type inference might not always be possible, e.g. look at the generic fetch example below -
1 2 3 4 5 6 7
async function fetchData<T>(url: string): Promise<T> { const res = await fetch(url); return res.json() } const user_1 = fetchData('xyz') // Promise<unknown> const user_2 = fetchData<User>('xyz') // Promise<User>
- sometimes, generic type inference might not work out of the box for us. we should provide the type ourselves in such cases -
1 2 3 4 5 6 7 8 9
const [colors, setColors] = useState([]); const handleClick = (color: string) => { setColors([...colors, color]); // we get this typescript error - "Type 'string' is not assignable to type 'never'" }; // solution - const [colors, setColors] = useState<string[]>([]);
- “generic type constraints” - TODO - fill with the notes
React + Typescript
- creating an app using vite -
npm create vite maps -- --template=react-ts
- for defining types that are not adhoc which for e.g. represent a domain in our app, i placed the file inside _types/place.d.ts. this then can be used by all components in our app
1 2 3 4 5 6
interface Place { id: number; place: string; latitude: number; longitude: number; }
- using typescript with “use state” hook. to remember - “type inference” might not work as expected with use state, so we might want to be explicit in such cases. e.g. without being explicit below, typescript assumes that place is of type
undefined
, because that is the effective initial value we pass to the use state hook1
const [place, setPlace] = useState<Place | undefined>();
- using typescript with props -
1 2 3 4 5 6 7 8 9
interface Props { place?: Place; } function Map({ place }: Props) { return ( // ... ); }
- we can add a callback prop as follows. important note - we are saying that the callback will not return any value. this way, we are effectively saying that this component would not access the value. the callback the parent provides still can return a value, and typescript would still work. however, the current component would not be able to use the returned value anyhow, unless the type of prop is fixed
1 2 3
interface Props { onPlaceClick: (place: Place) => void; }
- using form submission handlers in typescript notice the type of event -
1 2 3 4 5 6 7 8 9
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => { e.preventDefault(); setTerm(""); }; return ( <form onSubmit={handleSubmit}> // ... )
- importing typescript types -
1
import type { Map as LeafletMap } from "leaflet";