Upgrade to Pro — share decks privately, control downloads, hide ads and more …

React State Management

React State Management

Overview of state of state management in 2021

Dmitri Pisarev

May 06, 2021
Tweet

More Decks by Dmitri Pisarev

Other Decks in Programming

Transcript

  1. React Best Practices The great shift you might have missed

    in the recent years Dmitri Pisarev, Neos Conference 2021
  2. - Christian believer ❤ - Into web dev since 2005

    - Volunteer @ SFI.ru - Part-time @ DEED - team member Dmitri Pisarev @dimaip dimaip.github.io
  3. Immutability & type safety • TypeScript + typescript-eslint, as strict

    as possible • GraphQL + codegen • But… Next time!
  4. Passing state down via props-drilling PRO: • very explicit CONTRA:

    • cumbersome in a large app const ThemedButton = ({theme, setTheme}) => { const isLight = theme === "light" return ( <button onClick={() => setTheme(isLight ? "dark" : "light")} style={{ background: isLight ? "white" : "black", color: isLight ? "black" : "white" }} > Set {isLight ? "dark" : "light"} theme </button> ); }; const ManyLevelsOfNesting = ({theme, setTheme}) => <div><ThemedButton theme={theme} setTheme={setTheme} /></div> const App = () => { const [theme, setTheme] = React.useState("light"); return ( <ManyLevelsOfNesting theme={theme} setTheme={setTheme} /> ); };
  5. Context API const ThemeContext = React.createContext(undefined); const ThemedButton = ()

    => { const [theme, setTheme] = React.useContext(ThemeContext); const isLight = theme === "light" return ( <button onClick={() => setTheme(isLight ? "dark" : "light")} style={{ background: isLight ? "white" : "black", color: isLight ? "black" : "white" }} > Set {isLight ? "dark" : "light"} theme </button> ); }; const ManyLevelsOfNesting = () => <div><ThemedButton /></div> const App = () => { const themeTuple = React.useState("light"); return ( <ThemeContext.Provider value={themeTuple}> <ManyLevelsOfNesting /> </ThemeContext.Provider> ); }; PRO • easy to make things globally available • easier to prevent re-renders CONTRA • hard to optimise performance, e.g. create multiple contexts dynamically
  6. Is Redux Dead? import { Provider, useDispatch, useSelector } from

    "react-redux" import { configureStore, createSlice } from "@reduxjs/toolkit" const themeSlice = createSlice({ name: "theme", initialState: { theme: "light", }, reducers: { toggleTheme(state) { state.theme = state.theme === "light" ? "dark" : "light" }, }, }) const store = configureStore({ reducer: themeSlice.reducer, }) const ThemedButton = () => { const theme = useSelector((state) => state.theme) const dispatch = useDispatch() const isLight = theme === "light" return ( <button onClick={() => dispatch(themeSlice.actions.toggleTheme())} style={{ background: isLight ? "white" : "black", color: isLight ? "black" : "white", }} > Set {isLight ? "dark" : "light"} theme </button> ) } const ManyLevelsOfNesting = () => ( <div><ThemedButton /></div> ) const App = () => ( <Provider store={store}> <ManyLevelsOfNesting /> </Provider> ) PRO • Global state •Declarative state mutations •Devtools & ecosystem •Battle-tested CONTRA •Boilerplate
  7. Doesn’t Redux use context? • Only to pass down the

    store • Watch out for zombie children! function useSelector(selector) { const [, forceRender] = useReducer((counter) => counter + 1, 0); const { store } = useContext(ReactReduxContext); const selectedValueRef = useRef(selector(store.getState())); useLayoutEffect(() => { const unsubscribe = store.subscribe(() => { const storeState = store.getState(); const latestSelectedValue = selector(storeState); if (latestSelectedValue !== selectedValueRef.current) { selectedValueRef.current = latestSelectedValue; forceRender(); } }); return unsubscribe; }, [store]); return selectedValueRef.current; } “My personal summary is that new context is ready to be used for low frequency unlikely updates (like locale/theme). It's also good to use it in the same way as old context was used. I.e. for static values and then propagate updates through subscriptions. It's not ready to be used as a replacement for all Flux-like state propagation.” — Sebastian Markbage
  8. Recoil • boilerplate-free • flexible shared state • app-wide state

    observation (e.g. analytics) "Well, I know that on one tool we saw a 20x or so speedup compared to using Redux. This is because Redux is O(n) in that it has to ask each connected component whether it needs to re-render, whereas we can be O(1)."
  9. const themeAtom = atom({ key: 'themeState', default: 'light', }); const

    ThemedButton = () => { const [theme, setTheme] = useRecoilState(themeAtom); const isLight = theme === "light" return ( <button onClick={() => setTheme(isLight ? "dark" : "light")} style={{ background: isLight ? "white" : "black", color: isLight ? "black" : "white" }} > Set {isLight ? "dark" : "light"} theme </button> ); }; const ManyLevelsOfNesting = () => <div><ThemedButton /></div> const App = () => { return ( <RecoilRoot> <ManyLevelsOfNesting /> </RecoilRoot> ); }; const themeAtom = atom('light'); const ThemedButton = () => { const [theme, setTheme] = useAtom(themeAtom); const isLight = theme === "light" return ( <button onClick={() => setTheme(isLight ? "dark" : "light")} style={{ background: isLight ? "white" : "black", color: isLight ? "black" : "white" }} > Set {isLight ? "dark" : "light"} theme </button> ); }; const ManyLevelsOfNesting = () => <div><ThemedButton /></div> const App = () => { const themeTuple = React.useState("light"); return ( <JotaiProvider> <ManyLevelsOfNesting /> </JotaiProvider> ); }; Recoil Jotai
  10. zustand • single store approach • state lives outside of

    React, great to serve as bridge with non-React world const [useStore] = create((set) => ({ theme: 'light', toggleTheme: () => set((state) => ( { theme: state.theme === 'light' ? 'dark' : 'light' } )) })) const ThemedButton = () => { const { theme, toggleTheme } = useStore() const isLight = theme === 'light' return ( <button onClick={toggleTheme} style={{ background: isLight ? 'white' : 'black', color: isLight ? 'black' : 'white' }}> Set {isLight ? 'dark' : 'light'} theme </button> ) } const ManyLevelsOfNesting = () => ( <div> <ThemedButton /> </div> ) const App = () => { return <ManyLevelsOfNesting /> }
  11. useEffect const PostsList = () => { const [loading, setLoading]

    = useState(false) const [error, setError] = useState(false) const [posts, setPosts] = useState([]); useEffect(() => { setLoading(true) fetch("https://jsonplaceholder.typicode.com/posts") .then((r) => r.json()) .then((r) => { setPosts(r) setLoading(false) setError(false) }).catch(e => { setLoading(false) setError(true) }) }, []); if (postsError) { return <div>Error</div>; } if (loading) { return <div>Loading...</div>; } return ( <ul> {posts.map((post) => ( <li id={post.id}>{post.title}</li> ))} </ul> ); };
  12. Effects with Redux import { combineReducers } from "redux"; import

    { Provider, useDispatch, useSelector } from "react-redux"; import { configureStore, createSlice } from "@reduxjs/toolkit"; const postsSlice = createSlice({ name: "posts", initialState: { isFetching: false, posts: [], error: false }, reducers: { fetchStarted(state) { state.isFetching = true; }, fetchComplete(state, action) { state.posts = action.payload; state.isFetching = false; state.error = null; }, fetchFailed(state, action) { state.error = action.payload; state.isFetching = false; state.posts = []; } } }); const rootReducer = combineReducers({ posts: postsSlice.reducer }); const store = configureStore({ reducer: rootReducer }); const fetchPosts = () => async (dispatch) => { const { fetchStarted, setPosts, setError } = postsSlice.actions; dispatch(fetchStarted()); fetch("https://jsonplaceholder.typicode.com/posts") .then((r) => r.json()) .then((posts) => dispatch(fetchComplete(posts))) .catch((e) => dispatch(fetchFailed(e.toString()))); }; const PostList = () => { const posts = useSelector((state) => state.posts); const dispatch = useDispatch(); useEffect(() => { dispatch(fetchPosts()); }, []); return ( <ul> {posts.posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }; export default function App() { return ( <Provider store={store}> <PostList /> </Provider> ); } • redux-saga • redux-observable • redux-loop • etc
  13. Recoil + Suspense • Selector can return a Promise •

    Use Suspend to handle loading state CONTRA • We loose event semantics import { RecoilRoot, selector, useRecoilValue } from "recoil"; const postsQuery = selector({ key: "posts", get: () => fetch("https://jsonplaceholder.typicode.com/posts").then((r) => r.json()) }); function PostsList() { const posts = useRecoilValue(postsQuery); return ( <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); } const App = () => ( <RecoilRoot> <ErrorBoundary> <React.Suspense fallback={<div>Loading...</div>}> <PostsList /> </React.Suspense> </ErrorBoundary> </RecoilRoot> );
  14. RxJS import { of, timer, zip } from "rxjs"; import

    { map, switchMap, catchError, startWith } from "rxjs/operators"; import { fromFetch } from "rxjs/fetch"; import { useObservableState } from "observable-hooks"; const delayedFetch = (url) => { return zip(fromFetch(url).pipe(switchMap((r) => r.json())), timer(300)).pipe( map(([data]) => data) ); }; const PostsList = () => { const [ui] = useObservableState(() => delayedFetch("https://jsonplaceholder.typicode.com/posts").pipe( map((posts) => ( <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> )), catchError(() => of(<div>ERROR</div>)), startWith(<div>loading...</div>) ) ); return <div>{ui}</div>; } • learn once use anywhere • but steep learning curve • super powerful abstraction
  15. xState • very strong, math- backed abstraction import { useMachine

    } from '@xstate/react' import { Machine, assign } from 'xstate' const postsMachine = Machine({ id: 'posts', initial: 'idle', context: { posts: [], error: null, }, states: { idle: { on: { FETCH: 'loading', }, }, loading: { invoke: { id: 'getPosts', src: (context, event) => fetch('https://jsonplaceholder.typicode.com/posts').then((r) => r.json(), ), onDone: { target: 'success', actions: assign({ posts: (context, event) => event.data }), }, onError: { target: 'failure', actions: assign({ error: (context, event) => event.data }), }, }, }, success: {}, failure: { on: { RETRY: 'loading', }, }, }, }) export default function App() { const [current, send] = useMachine(postsMachine) useEffect(() => { send('FETCH') }, []) if (current.matches('loading') || current.matches('idle')) { return <div>Loading...</div> } return ( <ul> {current.context.posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ) }
  16. EffectorJS • state & effect management in one box •

    somewhat simpler than RxJS to learn • state + event + effect manager import { createEffect, createStore } from "effector"; import { useStore } from "effector-react"; const fetchPostsFx = createEffect(async () => fetch("https://jsonplaceholder.typicode.com/posts").then((r) => r.json()) ); fetchPostsFx(); const $posts = createStore([]).on( fetchPostsFx.doneData, (state, posts) => posts ); const $postsError = createStore(null).on( fetchPostsFx.failData, (state, error) => error ); const PostsList = () => { const posts = useStore($posts); const postsError = useStore($postsError); const loading = useStore(fetchPostsFx.pending); if (postsError) { return <div>Error</div>; } if (loading) { return <div>Loading...</div>; } return ( <ul> {posts.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ); }
  17. Types of state • Local component state • Server state

    (cache) • Global app state • Location state • Form state
  18. URL state • A lot of UI state can be

    based on a URL • Keep state on reload
  19. URL + xState const useOnboardingTransition = (): ((action) => void)

    => { const router = useRouter() return (action) => { const currentStep = (router.route.startsWith('/onboarding/') && router.query?.id?.[0]) || 'unknown' const nextStep = machine.transition(currentStep, action).value if (nextStep !== currentStep) { void router.push(`/onboarding/${nextStep}`) } } } const machine = Machine( { key: 'onboarding', initial: 'start', states: { start: { on: { EMAIL: 'create', SSO: 'createSSO', }, }, create: { on: { NEXT: 'details', SSO: 'createSSO', }, }, createSSO: { on: { NEXT: 'details', }, }, waitlist: { type: 'final', }, details: { on: { NEXT: 'assets', }, }, assets: { on: { PREV: 'details', NEXT: 'finished', }, }, finished: { type: 'final', }, unknown: { on: { RESUME: [ { target: 'details', cond: 'onboardingDetailsIncomplete' }, { target: 'assets', cond: 'onboardingAssetsIncomplete' }, { target: 'finished' }, ], }, }, }, }, { guards: { onboardingDetailsIncomplete: (_, event) => 'onboardingDetailsComplete' in event ? !event.onboardingDetailsComplete : false, onboardingAssetsIncomplete: (_, event) => 'onboardingAssetsComplete' in event ? !event.onboardingAssetsComplete : false, }, } ) const { t } = useTranslation('offererOnboardingAssets') const transition = useOffererOnboardingTransition() return <button onClick={() => transition('NEXT')}>Next</button>
  20. Server state vs. client state • Server state is just

    a cache • Caching is hard! • Once server state is gone client state often becomes much more lean
  21. Apollo Client / react-query import { useQuery, QueryCache, ReactQueryCacheProvider }

    from "react-query" const PostList = () => { const { status, data } = useQuery("posts", () => fetch("https://jsonplaceholder.typicode.com/posts").then((r) => r.json()) ) if (status === "loading") { return <div>Loading...</div> } if (status === "error") { return <div>Error!</div> } return ( <ul> {data.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> ) } const queryCache = new QueryCache() const App = () => ( <ReactQueryCacheProvider queryCache={queryCache}> <PostList /> </ReactQueryCacheProvider> )
  22. TL;DR • Not all state is created equal • Use

    specialised solutions for different state kinds • Every abstraction has a cost to pay • Simple abstractions for simple problems, 
 advanced abstractions for advanced ones • don’t be dogmatic and experiment!