Skip to content

Instantly share code, notes, and snippets.

@nilshartmann
Last active May 11, 2021 11:57
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save nilshartmann/6952fc504603f4339cb69801dab20567 to your computer and use it in GitHub Desktop.
Save nilshartmann/6952fc504603f4339cb69801dab20567 to your computer and use it in GitHub Desktop.

How to deal with forest/tree structures in a normalized Redux store?

A Redux-based application holds a forest-like data structure, like Folders and Documents in a file system, but with many root Folders, like this:

Folders and Documents

Example Use-Case

In a React Component, DocumentList we display all Documents (all leafs of all trees) and when clicking on one Document, we need access to the Document's root (Folder) object (to read some information from it)

DocumentList React Component

Example: When a User clicks on Document "4", we need to get the corresponding Root Folder "Abc"

👉 How would you handle this (receiving the Root folder) in your Redux applications?

👇 Find below some more informations about the state types

Application State

The data is stored in a normalized Redux Store, so Folder/Document objects have a pointer back to their parent object:

Folder and Documents have pointers to their parent

Folder and Documents are stored in separate "tables" in the redux state (one state/slice!)

Shape of Redux Store

Types

Redux State:

type DocumentAndFolderState = {
  documents: Array<Document>;
  foldersById: Record<string, Folder>;
}  

The types for our domain model and the state could look like this:

type Folder {
  id: string;
  label: string;
  slug: string;
  
  parentId?: string; // not set if this is a root folder
}  
type Document {
  id: string
  label: string
  parentId: string; // every document is inside a folder
}  

Accessing Documents and Folders

Please consider all code example as "concepts" / dummy code, rathern then working real code

If we want to retrieve a parent Folder of a Document or Folder:

  const myObject: Folder|Document = ...;
  const parentFolder = myObject.parentId ? state.foldersById[myObject.parentId] : null;

If we want to retrieve the root Folder for a Document or Folder you can recursively traverse from a leaf to the root:

  function getRootFolder(state, document: Document) {
    let parentId = document.parentId;
    let parent;
    while (parentId) {
      parent = state.foldersById[parentId];
      parentId = parent.parentId;
    }
    
    return parent;
  }

The use-case

Imagine, we have a React component that shows all our Documents. No further information from the store is needed until some user clicks one one the Documents. In that case the root folder for this single Document is needed.

Think of this example: by clicking a Document the user should be navigated (by changing the URL), to a page where the root Folder is displayed. The url is part of the Folder object (slug). This information (slug) is not needed until the user clicks on a Document in the list.

The List component is part of a page that allows allows adding/chageing/removing documents, so the Store could change, while the List component is visible.

My question is, how and when do we determine the root folder of a Document in that component.

The component might look like this, and I wonder what is the best way to get the root folder in handleClick.

(Note that the code is simplified, read it more like a concept, than real code)

function SelectDocument() {
  const documents = useSelector(state => state.documents); 
  
  function handleClick(document: Document) {
    // for some reason we need the root folder for the document here
    
    const rootFolder = ???;
    
    // We don't want to CHANGE the folder, only need some information, for example
    // to navigate to a URL:
    history.push(rootFolder.slug);
  }
  
  return <ul>
    {documents.map(d => <li key={d.id} onClick={d => handleClick(d)}>{d.label}</li>)}
  </ul>
}

For this task I came up with the following options:

Option 1: Select it in a selector

function SelectDocument() {
  const documents = useSelector(state => Object.values(state.documentsById);
  const documentIdsToRootFolders: Record<string, Folder> = useSelector(state => {
    const result = {};
    
    documents.forEach(d => {
      result[d.id] = getRootFolder(state, d);
    })
    
    return result;
  }); // assume shallow equals here
  
  function handleClick(document: Document) {
    // for some reason we need the root folder for the document here
    
    const rootFolder = documentIdsToRootFolders[document.id];
}
  • Pro: classic Redux way, things change, component re-renders has all data it needs
  • Con: determining the root folders for all docs might be expensive and is done anytime anything changes in the store
  • Variation: maybe we could improve by using a persistent selctor (re-select)

Option 2: De-normalize

We could add a rootFolderId property to our Document and Folder types. This way we could simplify the selector:

function SelectDocument() {
  const documents = useSelector(state => Object.values(state.documentsById);
  const documentIdsToRootFolders: Record<string, Folder> = useSelector(state => {
    const result = {};
    
    documents.forEach(d => {
      result[d.id] = state.foldersById[d.rootFolderId]
    })
    
    return result;
  }); // assume shallow equals here
  
  function handleClick(document: Document) {
    // same as option 1
    
    const rootFolder = documentIdsToRootFolders[document.id];
}
  • Pro: might speed things up (depending on the depth of the tree)
  • Con: need to make sure the rootFolderId is always up-to-date (what happens if a Document is moved to another folder?)
  • Variation: only select all root folder (those where parentId is undefined) in the selector, than find the one that is searched in handleClick (Array.find or something similiar)

Option 3: Cache in state

Instead of a rootFolderId (as in Option 2) we might store this connection in the state itself:

type DocumentAndFolderState = {
  documents: Array<Document>;
  foldersById: Record<string, Folder>;
  
  // cached value!
  documentIdToRootFolderId: Record<string, string>;
}  
  • Pro: This would reduce the selector even more
  • Con: as in Option 2, we have to make sure, that the cache is always up-to-date

Option 4: Access the store in the Event Handler

We could select the list of Documents, but not the list of Root Documents. Instead we read it from the state only when the Document is clicked (and we really need the Document):

function SelectDocument() {
  const documents = useSelector(state => Object.values(state.documentsById);
  const store = useStore();
  
  function handleClick(document: Document) {
    // same as option 1
    
    const rootFolder = getRootFolder(store.getState(), document);
}
  • Pro: we determine the root folders only in case we really need to. Not reactive, but remember: none of the informations from the root component is needed for rendering.
  • Con: not a typical Redux usage?!

Questions 🤔🤔🤔

  • Which option do you like most?
  • are there any more ideas?
  • thanks a lot for your hints, comments and thoughts!
@ghost23
Copy link

ghost23 commented May 11, 2021

You could do a combination of 1 and 4.

  • Upon clicking set a local flag showRootFolder or something
  • Make a selector that selects the root folder or return null depending on that flag

@nilshartmann
Copy link
Author

Thanks, @ghost23!

You could do a combination of 1 and 4.

* Upon clicking set a local flag `showRootFolder` or something

* Make a selector that selects the root folder or return null depending on that flag

Interesting idea, but I need the information in my event handler. Using a local flag, I would re-render, selector selects root folder (great!) but unfortunately... too late :-/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment