Search code examples
javascriptrecoiljs

Looking for a pattern to normalize state in Recoil without losing the benefit of Suspense


In RecoilJS, seamless integration with React Suspense for async selectors is a big plus. However, I am running into issues trying to normalize the data cached in Recoil, while still making use of Suspense.

To explain the problem through an example, a User might have a collection of Books. A query populates the collection with a single API call to get all the user's "Favorite" books. A later query might simply request a single book, which may or may not have already been retrieved through the favorite books query.

What I'd like to do it maintain a normalized cache of Books, such as in an AtomFamily keyed by bookId, so I don't have two copies of books that are pulled with different queries. However, I run into a problem, which is that I would like to use Suspense for any one of the queries that retrieves one or more Books. And the natural way to do that with Recoil is to use an async Selector. But I don't see it, if there's a way to normalize the data fetched through async selectors.

Is there a pattern I am overlooking, that would allow me to use async selectors representing different queries that are backed by a shared, normalized AtomFamily?

For example, if I have this BAD code, which creates duplicate objects in my state, how might I rework it to maintain a shared cache for the actual Book objects, and still make use of Suspense if a query is still fetching when a component that uses this state renders?

Query 1: get a group of books through a selector:

const favoriteBooksSelector = selector({
  key: 'MyFavoriteBooks',
  get: async ({ get }) => {
    const response = await allMyFavorityBooksDBQuery({
      userID: get(currentUserIDState)
    });
    return response.books;
  },
});

Query 2: get a single book, looks something like:

  export const singleBookSelector = selectorFamily({
    key: 'singleBookSelector',
    get: (bookId: string) => async ({ get }) => {
        const response = await singleBookDBQuery({
            userID: get(currentUserIDState)
          });
          return response.book;
    }
  });


Solution

  • To utilize a cache, it must be indexed (keyed). For your example case, it is sensible to key a cache by book ID, so a KV cache is a reasonable choice. In JavaScript, a Map is a natural choice for such a cache.

    Below, I have composed a fully-functional example of how to implement such a cache as a primary source for some Recoil atomFamily instances. The code is commented, and I can provide more explanation if anything is unclear.

    An increasing query count is displayed as proof of the effectiveness of the cache. I have also included a link to the code in the TypeScript Playground for evaluation. If you would like to modify the code, all you need to do is copy it into a new answer (or just copy and paste it into local text editor and save it as an HTML file, and then serve it via a local http server).

    TS Playground

    <div id="root"></div><script src="https://unpkg.com/[email protected]/umd/react.development.js"></script><script src="https://unpkg.com/[email protected]/umd/react-dom.development.js"></script><script src="https://unpkg.com/[email protected]/umd/recoil.min.js"></script><script src="https://unpkg.com/@babel/[email protected]/babel.min.js"></script><script>Babel.registerPreset('tsx', {presets: [[Babel.availablePresets['typescript'], {allExtensions: true, isTSX: true}]]});</script>
    <script type="text/babel" data-type="module" data-presets="tsx,react">
    
    // import ReactDOM from 'react-dom';
    // import {default as React, Suspense, useEffect, useState, type ReactElement, type ReactNode} from 'react';
    // import {atomFamily, RecoilRoot, useRecoilValue} from 'recoil';
    
    // This Stack Overflow snippet demo uses UMD modules instead of the above import statments
    const {Suspense, useEffect, useState} = React;
    const {atomFamily, RecoilRoot, useRecoilValue} = Recoil;
    
    type Book = {
      author: string;
      id: string;
      title: string;
    };
    
    
    // Database simulation:
    
    // The simulated database
    const db = new Map<string, Book>();
    // Scraped from https://www.penguin.co.uk/articles/2018/100-must-read-classic-books.html#100
    (JSON.parse(`[{"author":"Jane Austen","title":"Pride and Prejudice","id":"BnuQKALlW6B6sZNU4bdaB"},{"author":"Harper Lee","title":"To Kill a Mockingbird","id":"UM3ms9hlnTbEmx44JknKc"},{"author":"F. Scott Fitzgerald","title":"The Great Gatsby","id":"hBl51iaNCQ8qZw5iec8hD"},{"author":"Gabriel García Márquez","title":"One Hundred Years of Solitude","id":"CC9hIXCdEHR0beJlbMF_y"},{"author":"Truman Capote","title":"In Cold Blood","id":"l0iJfZNmNBfioHDnHARWQ"},{"author":"Jean Rhys","title":"Wide Sargasso Sea","id":"D0UY9kmrV6HbqlIMspVwn"},{"author":"Aldous Huxley","title":"Brave New World","id":"rK2ks0GbZBDQPns-ZDEyW"},{"author":"Dodie Smith","title":"I Capture The Castle","id":"flTB4dqKfg1PWcUI6KtH2"},{"author":"Charlotte Bronte","title":"Jane Eyre","id":"3x-S6EsNUTZ5l_sESamF_"},{"author":"Fyodor Dostoevsky","title":"Crime and Punishment","id":"ntH3G63fMVKUud6rRhDbY"},{"author":"Donna Tartt","title":"The Secret History","id":"ubrxbS1-7NEr_lml6I8Q3"},{"author":"Jack London","title":"The Call of the Wild","id":"friqBlVlEY3eg2cpkgUET"},{"author":"John Wyndham","title":"The Chrysalids","id":"wRMQGG1QYaeVXXP_ghl-x"},{"author":"Jane Austen","title":"Persuasion","id":"YoMqTM9PhAfctMBqSdz6P"},{"author":"Herman Melville","title":"Moby-Dick","id":"Kd0Oggfkf5AQPGBqpw_iE"},{"author":"C.S. Lewis","title":"The Lion, the Witch and the Wardrobe","id":"-jD0Ujt-r54xbKZ_7Jv59"},{"author":"Virginia Woolf","title":"To the Lighthouse","id":"1TJQYcP6_hwm2syHUH8Dv"},{"author":"Elizabeth Bowen","title":"The Death of the Heart","id":"dl1qbyM0cHdmYUHKhTyZk"},{"author":"Thomas Hardy","title":"Tess of the d'Urbervilles","id":"_i6SLfaMpXRuhVqEH5Jhp"},{"author":"Mary Shelley","title":"Frankenstein","id":"ZPL-swiUogF-_gdabf9qv"},{"author":"Mikhail Bulgakov","title":"The Master and Margarita","id":"x0pw07n3o2KljHZM11isw"},{"author":"L. P. Hartley","title":"The Go-Between","id":"l0jHUSb4bY64k-l9Qed5Z"},{"author":"Ken Kesey","title":"One Flew Over the Cuckoo's Nest","id":"SCKsZTWD2QMsNomUie_Vf"},{"author":"George Orwell","title":"Nineteen Eighty-Four","id":"JscV73l2tSdm5W4kZSvZn"},{"author":"Thomas Mann","title":"Buddenbrooks","id":"f0XqwYfsWJ-w9J18b5FCD"},{"author":"John Steinbeck","title":"The Grapes of Wrath","id":"OnXfkmQEAL7sSQ3PgSV9z"},{"author":"Toni Morrison","title":"Beloved","id":"n3_aZgBlQkphqPTvmJGr6"},{"author":"P. G. Wodehouse","title":"The Code of the Woosters","id":"TzD6k5flXf8HMdfgSacMT"},{"author":"Bram Stoker","title":"Dracula","id":"_WPS6E_6uXVKWX0r2Sop6"},{"author":"J. R. R. Tolkien","title":"The Lord of the Rings","id":"bIzyksKmB0plzGwWI6h7l"},{"author":"Mark Twain","title":"The Adventures of Huckleberry Finn","id":"ctQZfUT_tsujBCdYkv4HA"},{"author":"Charles Dickens","title":"Great Expectations","id":"ULj9NAatfo8tCCe39YZTY"},{"author":"Joseph Heller","title":"Catch-22","id":"bOOUBZK7oFVDRrevxApvN"},{"author":"Edith Wharton","title":"The Age of Innocence","id":"ZJ8y0y-BbnaH5A9TulxgN"},{"author":"Chinua Achebe","title":"Things Fall Apart","id":"eahxg8sFYsudKEl9hocJv"},{"author":"George Eliot","title":"Middlemarch","id":"TLNUskf7TspVe3AOEV4nX"},{"author":"Salman Rushdie","title":"Midnight's Children","id":"0_DeHTlQpW4ffy-liu2R-"},{"author":"Homer","title":"The Iliad","id":"D9cyf2yCAwhnASsxGxtTd"},{"author":"William Makepeace Thackeray","title":"Vanity Fair","id":"YmXxLcLMYmuFkp39Q1aAa"},{"author":"Evelyn Waugh","title":"Brideshead Revisited","id":"p3D_ZtFdhT2Eytv7swOAZ"},{"author":"J.D. Salinger","title":"The Catcher in the Rye","id":"3Sf-5_lsdGVeiWJeSZZQI"},{"author":"Lewis Carroll","title":"Alice’s Adventures in Wonderland","id":"TJJ6J8OHF5PRaiHLEcPdq"},{"author":"George Eliot","title":"The Mill on the Floss","id":"F6S5twxijUt7cSvuoSeKH"},{"author":"Anthony Trollope","title":"Barchester Towers","id":"0jYVd6dhiSF1tJYuIU8az"},{"author":"James Baldwin","title":"Another Country","id":"xRjGwu2vOQObLqbFccnw_"},{"author":"Victor Hugo","title":"Les Miserables","id":"GR24l64YVjFagi-SB1Y-H"},{"author":"Roald Dahl","title":"Charlie and the Chocolate Factory","id":"CAoAoALD3T8wxX0Eevabi"},{"author":"S. E. Hinton","title":"The Outsiders","id":"XYhNMkKTKsh9aNGh24fvZ"},{"author":"Alexandre Dumas","title":"The Count of Monte Cristo","id":"Igcm-Wxq2Uf8vKjBr-D7j"},{"author":"James Joyce","title":"Ulysses","id":"GiianKDQPQVTIaFoFhy6H"},{"author":"John Steinbeck","title":"East of Eden","id":"belUus-Sta74zWfjTiuMW"},{"author":"Fyodor Dostoyevsky","title":"The Brothers Karamazov","id":"wp9JOJ0B8lKmxG0siRuR4"},{"author":"Vladimir Nabokov","title":"Lolita","id":"tvnoXyLsd-PtVmiwZLnM8"},{"author":"Frances Hodgson Burnett","title":"The Secret Garden","id":"VZyJI95JMwkj4rJOJbzzn"},{"author":"Evelyn Waugh","title":"Scoop","id":"QYgFDNe1S0x5V_ub-Vc-S"},{"author":"Charles Dickens","title":"A Tale of Two Cities","id":"G0FUeqOiLuNnBNEr4XPD2"},{"author":"George Grossmith and Weedon Grossmith","title":"Diary of a Nobody","id":"PLi0tMjdAZI54P3U02B2N"},{"author":"Leo Tolstoy","title":"Anna Karenina","id":"E0OlPZ9F8Z3rsEmGihW-0"},{"author":"Alessandro Manzoni","title":"The Betrothed","id":"hPHRkfbcMUeJUejXy7spa"},{"author":"Virginia Woolf","title":"Orlando","id":"FSzptVHC-ICRl0tlPhS-O"},{"author":"Ayn Rand","title":"Atlas Shrugged","id":"CdzIlNo9jp5CDAP5BEwLi"},{"author":"H. G. Wells","title":"The Time Machine","id":"dQn4oEs0hqgfuaFR13S-o"},{"author":"Sun-Tzu","title":"The Art of War","id":"LZwoJLEtLv4Dx2QnUBvwM"},{"author":"John Galsworthy","title":"The Forsyte Saga","id":"p9hOPd4gC7PKX9bbp8JVZ"},{"author":"John Steinbeck","title":"Travels with Charley","id":"c3LtQi5_p-XSF2JSfPOjq"},{"author":"Henry Miller","title":"Tropic of Cancer","id":"iFILNdFzltGXugvwpUjSS"},{"author":"D. H. Lawrence","title":"Women in Love","id":"gYf7mAVCM_SX5e3NDwc9y"},{"author":"Paul Scott","title":"Staying On","id":"gZYOkRz4APlcDGNH5onYD"},{"author":"Kenneth Grahame","title":"The Wind in the Willows","id":"epTCvsskVjm3vnomZCPRw"},{"author":"Willa Cather","title":"My Ántonia","id":"wWoBKiKEQ6KpwigH2RtMQ"},{"author":"Emily Brontë","title":"Wuthering Heights","id":"8Feh8HOHmfFZXwhkclUmj"},{"author":"Patrick Süskind","title":"Perfume","id":"JJntMbxqiKvuryEO82VAX"},{"author":"Leo Tolstoy","title":"War and Peace","id":"CPfDnuxwDYeLvzqLPJzXJ"},{"author":"Somerset Maugham","title":"Of Human Bondage","id":"h4IW8mQUmLTJ9uyfVe2qe"},{"author":"Charles Dickens","title":"Bleak House","id":"NPkSH3PieOiq_gE0svlxB"},{"author":"Honoré de Balzac","title":"Lost Illusions","id":"0Ckpg5CMzAYIUbCjWZXPt"},{"author":"Kurt Vonnegut","title":"Breakfast of Champions","id":"Lydqp4eMEkYL3YVkg0krr"},{"author":"Charles Dickens","title":"A Christmas Carol","id":"ApOCi4LPkvoN2R47C1frw"},{"author":"George Eliot","title":"Silas Marner","id":"5CUwpkfRyLjTBBmJHc0Ic"},{"author":"Virginia Woolf","title":"Mrs Dalloway","id":"9Pdh2b7of93bT-Xp1egBB"},{"author":"Louisa May Alcott","title":"Little Women","id":"095_BrLfJD-pI2nOtqJII"},{"author":"Iris Murdoch","title":"The Sea, The Sea","id":"5V4JjZvcqWhiLTdpYjc5r"},{"author":"Mario Puzo","title":"The Godfather","id":"cK1YXvMZ4xRZVFyQDKcG3"},{"author":"Franz Kafka","title":"The Castle","id":"bV5hrXcPzSfPhLPITPlj7"},{"author":"Robert Graves","title":"I, Claudius","id":"2FFaA72V-Pp74A6mZajR7"},{"author":"J.M. Barrie","title":"Peter Pan","id":"6vwOgrhQTp60ISU-KIxoQ"},{"author":"John Kennedy Toole","title":"A Confederacy of Dunces","id":"zZwqEBfR72Ht_Uwa25blx"},{"author":"W. Somerset Maugham","title":"The Razor's Edge","id":"uL-eIpi0xf11BDmpxfxYQ"},{"author":"Flora Thompson","title":"Lark Rise to Candleford","id":"wISh6hRf-rIOXzGV9pReU"},{"author":"Thomas Hardy","title":"The Return of the Native","id":"ouX9cTm5gF36zX95SfOaE"},{"author":"James Joyce","title":"A Portrait of the Artist as a Young Man","id":"dX6B1SNtZH_Kij9ZdQ3cx"},{"author":"Joseph Conrad","title":"Heart of Darkness","id":"uQk4tRerBAtFtZwh-Xyx3"},{"author":"Elizabeth Gaskell","title":"North and South","id":"8bRGCx_5Pk3i4-RNXlley"},{"author":"Margaret Atwood","title":"The Handmaid's Tale","id":"E0tJsPHR6JnnoQ9UKtKHE"},{"author":"Irene Nemirovsky","title":"Suite Francaise","id":"0lq5lUjV7A0SMvUF-ucmv"},{"author":"Alexander Solzhenitsyn","title":"One Day in the Life of Ivan Denisovich","id":"3Qik1V1BoZZDyPphzedzb"},{"author":"Jonathan Coe","title":"What A Carve Up!","id":"UhNcCOU_TzUDbTOvxzUPU"},{"author":"Robert Pirsig","title":"Zen and the Art of Motorcycle Maintenance","id":"Alpfu_s-Ee8L6G1s7-WD2"},{"author":"Fyodor Dostoyevsky","title":"White Nights","id":"Lr3KmI-pOxer7rSsF8MhE"},{"author":"Charles Dickens","title":"Hard Times","id":"OrxuKkQoEgg2cSDQcyyPc"}]`) as Book[])
      .forEach(book => db.set(book.id, book));
    
    function delay (ms: number): Promise<void> {
      return new Promise(resolve => setTimeout(resolve, ms));
    }
    
    function randomInt (min = 0, max = 1): number {
      return Math.floor(Math.random() * (max - min + 1)) + min;
    }
    
    // Simulated db methods
    const booksDb = {
      async getOne (id: string): Promise<Book | undefined> {
        return (await this.getMany([id]))[0];
      },
      async getMany (ids: string[]): Promise<(Book | undefined)[]> {
        await delay(randomInt(50, 500));
        return ids.map(id => {
          const book = db.get(id);
          // Simulate getting a copy every time
          return book ? {...book} : undefined;
        });
      },
    };
    
    
    // Recoil state:
    
    // Cached results
    const booksCache = new Map<string, Book>();
    
    // Just for this demo, maintain a query count
    let dbQueryCount = 0;
    
    // Inspired by effector, I prefix recoil-related variables with $ to simplify naming
    const $book = atomFamily<Book | undefined, string>({
      key: 'book',
      default: async (id) => {
        // Return from cache, querying db only if unavailable
        if (!booksCache.has(id)) {
          dbQueryCount += 1;
          const book = await booksDb.getOne(id);
          if (book) booksCache.set(id, book);
        }
        return booksCache.get(id);
      },
    });
    
    const $books = atomFamily<(Book | undefined)[], string[]>({
      key: 'books',
      default: async (ids) => {
        const books: (Book | undefined)[] = [];
        const available: [index: number, id: string][] = [];
        const unavailable: [index: number, id: string][] = [];
    
        // Split query into collections of available in cache or not
        for (const [index, id] of ids.entries()) {
          const isAvailable = booksCache.has(id);
          (isAvailable ? available : unavailable).push([index, id]);
        }
    
        // Get cached results
        for (const [index, id] of available) {
          books[index] = booksCache.get(id);
        }
    
        // Query the remaining with a single network request
        dbQueryCount += 1;
        const booksFromDb = await booksDb.getMany(unavailable.map(([, id]) => id));
    
        // Update cache and finalize
        for (const [index, id] of unavailable) {
          const book = booksFromDb[index];
          if (book) booksCache.set(id, book);
          books[index] = booksCache.get(id);
        }
    
        return books;
      },
    });
    
    
    // Components:
    
    function BookComponent ({book}: { book: Book | undefined }): ReactElement {
      if (!book) return (<div>Book is not availble</div>);
      return (
        <div>
          <em>{book.title}</em> by <span>{book.author}</span>
        </div>
      );
    }
    
    function BookFromId ({id}: { id: string }): ReactElement {
      const book = useRecoilValue($book(id));
      return <BookComponent {...{book}} />;
    }
    
    function BookCollection ({ids}: { ids: string[]; }): ReactElement {
      // To see these loaded individually, uncomment the following lines:
    
      // return (<div>{ids.map((id, index) => (
      //   <BookFromId {...{id, key: `${index}-${id}`}} />
      // ))}</div>);
    
      const books = useRecoilValue($books(ids));
    
      return (<div>{books.map((book, index) => (
        <BookComponent {...{book, key: `${index}-${book?.id}`}} />
      ))}</div>);
    }
    
    function LoadingDiv ({children}: { children?: ReactNode }): ReactElement {
      return (<div>{children}</div>);
    }
    
    const collections: [title: string, ids: string[]][] = [
      ['Titles starting with A', ['ApOCi4LPkvoN2R47C1frw', 'zZwqEBfR72Ht_Uwa25blx', 'dX6B1SNtZH_Kij9ZdQ3cx', 'G0FUeqOiLuNnBNEr4XPD2', 'TJJ6J8OHF5PRaiHLEcPdq', 'E0OlPZ9F8Z3rsEmGihW-0', 'xRjGwu2vOQObLqbFccnw_', 'CdzIlNo9jp5CDAP5BEwLi']],
      ['Titles starting with B', ['0jYVd6dhiSF1tJYuIU8az', 'n3_aZgBlQkphqPTvmJGr6', 'NPkSH3PieOiq_gE0svlxB', 'rK2ks0GbZBDQPns-ZDEyW', 'Lydqp4eMEkYL3YVkg0krr', 'p3D_ZtFdhT2Eytv7swOAZ', 'f0XqwYfsWJ-w9J18b5FCD']],
      ['Titles starting with C', ['bOOUBZK7oFVDRrevxApvN', 'CAoAoALD3T8wxX0Eevabi', 'ntH3G63fMVKUud6rRhDbY']],
    ];
    
    type OrPromise<T> = T | Promise<T>;
    
    function useLazyValue <T>(initialValue: T, producer: () => OrPromise<T>): T {
      const [value, setValue] = useState(initialValue);
    
      const updateValue = async () => {
        const result = await producer();
        if (value !== result) setValue(result);
      };
    
      useEffect(() => void updateValue());
      return value;
    }
    
    function App (): ReactElement {
      const [collectionIndex, setCollectionIndex] = useState(0);
      const collectionIds = collections[collectionIndex]![1];
      const queryCount = useLazyValue(0, () => dbQueryCount);
    
      const booksLoading = <LoadingDiv>The collection is loading...</LoadingDiv>;
    
      return (
        <div style={{
          display: 'flex',
          flexDirection: 'column',
          gap: '0.5rem',
          fontFamily: 'sans-serif',
        }}>
          <h1>Recoil book cache</h1>
    
          <div>Query count: {queryCount}</div>
    
          <label>
            <div>Select a collection:</div>
            <select
              onChange={ev => setCollectionIndex(Number(ev.target.value))}
              value={collectionIndex}
            >{collections.map(([title], index) => (
              <option key={`${index}-${title}`} value={index}>{title}</option>
            ))}</select>
          </label>
    
          <Suspense fallback={booksLoading}>
            <BookCollection ids={collectionIds} />
          </Suspense>
        </div>
      );
    }
    
    function AppRoot (): ReactElement {
      return (
        <RecoilRoot>
          <App />
        </RecoilRoot>
      );
    }
    
    ReactDOM.render(<AppRoot />, document.getElementById('root'));
    
    
    </script>