Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Question] Best practices for large objects? #255

Closed
jillesme opened this issue Jan 12, 2021 · 15 comments · Fixed by #400
Closed

[Question] Best practices for large objects? #255

jillesme opened this issue Jan 12, 2021 · 15 comments · Fixed by #400

Comments

@jillesme
Copy link

jillesme commented Jan 12, 2021

Hi all,

So far we are loving jotai. Amazing work ❤️.

I'd like to start a conversation around best practices for larger objects. Let's say our back-end gives us a fairly large object which would be mutable at different levels of the object.

const cars = [
    {
        make: 'Audi',
        model: 'RS6',
        price: {
            value: 999.50,
            currency: 'USD'
        },
        parts: [
            {
                type: 'engine',
                power: 'lot-of hp',
                price: { ... },
                factoryLocations: [
                    { country: 'China', stock: 150 },
                    { country: 'Germany', stock: 0 },
                ]
            }, // ... n more
        ]
    },
    // ... n more
]

Now our React component tree has <Car /> with a list of <Parts />, which has a list of <Factory />s.

What would be the recommended way to go about

  • Adding / removing parts
  • Adding / updating factory locations
  • Updating price

Using jotai? I am personally very familiar with MobX, where this would be one large observable object (or class instance). However, I am not sure how to look at this in atoms.

Would each car be an atom, with parts turned into atoms and factory locations into atoms? Any other approaches?

I'd love to hear thoughts on this! 😊

@dai-shi
Copy link
Member

dai-shi commented Jan 12, 2021

OK, this would be a good example for discussion. Thanks for opening this up. Please everyone join.

Some meta answers for a start.

  • There are probably various good/possible patterns for this.
  • We are still discussing the recommended patterns for lists (before trees).
  • In short, what you feel comfortable would be the right granularity.

I will write some more thoughts later (maybe starting with lists.)

A quick question, does the objects in arrays have ID? Like what you would specify to JSX key={}?

@jillesme
Copy link
Author

@dai-shi thank you for the quick response!

The objects in arrays do not have ids in this example, but they very well could have (either a real id from the server, or an assigned id on the client side).

One things that comes to mind is using https://github.com/paularmstrong/normalizr to turn the deeply nested object into a flatter structure. I fail to see how that would help with jotai, but I believe it is what the Redux community does.

I'm very interested in the recommended patterns for lists and trees.

@dai-shi
Copy link
Member

dai-shi commented Jan 13, 2021

So, there are mainly three approaches.

  1. a big atom to put an object as a whole and create smaller derived atoms
  2. create a map of (id, item), normalized data structure
  3. create small atoms and combine them with atom references

a big atom

This idea is not very atomic, but it works. This is useful if we need to persist data or push back to the server.

const dataAtom = atom({
  people: [
    { name: 'Luke Skywalker', height: '172' },
    { name: 'C-3PO', height: '167' },
  ],
  films: [
    { title: 'A New Hope', episode_id: 4, planets: [{ name: 'Tatooine' }, { name: 'Alderaan' }] },
    { title: 'The Empire Strikes Back', episode_id: 5, planets: [{ name: 'Hoth' }] },
  ],
}

const peopleAtom = atom(get => get(dataAtom).people)
const filmsAtom = atom(get => get(dataAtom).films)

const createFilmAtom = index => atom(get => get(filmsAtom)[index])

(Wait a minute, I might notice a missing optimization in the current implementation...)

There are utility functions to ease this pattern: selectAtom, splitAtom, focusAtom

normalized data

Like you implied normalizr.

const peopleMapAtom = atom({
  p1: { name: 'Luke Skywalker', height: '172' },
  p2: { name: 'C-3PO', height: '167' },
})

const planetsMapAtom = atom({
  a1: { name: 'Tatooine' },
  a2: { name: 'Alderaan' },
  a3: { name: 'Hoth' },
})

const filmsMapAtom = atom({
  f1: { title: 'A New Hope', episode_id: 4, planets: ['a1', 'a2'] },
  f2: { title: 'The Empire Strikes Back', episode_id: 5, planets: ['a3'] },
})

There are utility functions to ease this pattern: atomFamily

atom references

This looks crazy, but it works.

const dataAtom = atom({
  people: atom([
    atom({ name: 'Luke Skywalker', height: '172' }),
    atom({ name: 'C-3PO', height: '167' }),
  ]),
  films: atom([
    atom({ title: 'A New Hope', episode_id: 4, planets: [atom({ name: 'Tatooine' }), atom({ name: 'Alderaan' })] }),
    atom({ title: 'The Empire Strikes Back', episode_id: 5, planets: [atom({ name: 'Hoth' })] }),
  ],
}

This is pseudo code. We don't manually create like this.
It's up to you at which level you top making atoms. For example, we don't need to wrap with atoms for planets.

To support this pattern, atom returns a unique string, so you can specify it to key={}.


I would personally would like to recommend the third pattern. It's pretty much like jotai, the power of it. Having nested atoms is tricky, though. I wonder how DX would be like. TS is almost necessary for this.

The first pattern would be good for persistence or server cache. The second one might be easier to understand, especially with atomFamily.

@sandren
Copy link
Collaborator

sandren commented Jan 14, 2021

  1. create small atoms and combine them with atom references

The third pattern looks interesting. Could you demonstrate what it might look like in an actual implementation? Thanks! 😄

@dai-shi
Copy link
Member

dai-shi commented Jan 14, 2021

@sandren
I made my recent drawing poc app with this pattern: https://twitter.com/dai_shi/status/1349749474483003394
Also, the todo app I developed before: https://codesandbox.io/s/jotai-todos-ijyxm

Both are just lists, not trees.

@garretteklof
Copy link

Hi @dai-shi - I've been experimenting with some of these patterns in one of my applications, and I had a question wrt atomFamily. I have an atom that is a list of ids that point to a family with an areEqual function (much like your atomFamily todo codesandbox). I have another atom that is a filtered list. My question is what if we want to filter the list based on a property in the family, but only want components to update when the property that it is being filtered by changes? What is the best practice for this?

I have discovered focusAtom and what I came up with is something like this: (note: interactive prop is a boolean)

const interactiveLayersAtom = atom(get => get(layersAtom).filter(id =>
    get(focusAtom(layerAtomFamily({ id }), optic => optic.prop("interactive")))
  ))

Admittedly, this seems a little convoluted, but it does work. Any suggestions, pointing in the right direction is much appreciated. Thanks again for your amazing work.

@dai-shi
Copy link
Member

dai-shi commented Feb 20, 2021

There’re probably several ways. What would be easy for your current setup is selectAtom with equalityFn. But, that requires to create an atom holding filtered list of atoms. So, the way you come up with is not that bad.

This is jotai puzzle. 🤔 Do you want only solutions to keep using atomFamily?

@dai-shi
Copy link
Member

dai-shi commented Feb 20, 2021

Updated #255 (comment) .
We now have selectAtom instead of useSelector and a new splitAtom.

@garretteklof
Copy link

garretteklof commented Feb 21, 2021

Thanks for the quick reply. I'm always astonished by both your knowledge and promptness!

I am just experimenting with patterns so I have no constraints to keep using atomFamily. Now, I'm trying out a list of atoms (seems like may be the most jotai way as # 3 above) - I used a selectAtom to create a list of ids:

const interactiveLayersAtom = atom(get =>
  get(layersAtom).flatMap(layerAtom => {
    const modAtom = selectAtom(
      layerAtom,
      ({ id, interactive }) => ({
        id,
        interactive,
      }),
      (a, b) => a.id === b.id && a.interactive === b.interactive
    )
    const { interactive, id } = get(modAtom)
    return interactive ? [id] : []
  })
)

But the issue I'm running into with this is that I don't have the ability to set individual layers. I think I'm creating an anti-pattern. Here is the hook I have that creates an atom and pushes to an array. Calling setLayer in component triggers the above, but changes don't reflect in the hook.

const useLayer = params => {
  const { visible = true, interactive = true, source, label, id } = params
  const [, setLayers] = useLayers()
  const layerAtom = atom({ id, source, label, visible, interactive })
  const [layer, setLayer] = useImmerAtom(layerAtom)
  useEffect(() => {
    setLayers(state => void state.push(layerAtom))
  }, [])
  console.log('This does not update when setLayer called in component', layer)
  return [layer, setLayer]
}

I'm guessing I should just be creating another atom that pulls and returns the atom from the list?

@dai-shi
Copy link
Member

dai-shi commented Feb 21, 2021

It sounds like there's some misunderstanding.

seems like may be the most jotai way as # 3 above

Yeah. But, with # 3, I wouldn't use selectAtom.

  const layerAtom = atom({ id, source, label, visible, interactive })

You can't create atom in render. If necessary, use useMemo.


Let's try something simple.

const itemsAtom = atom([]) // array of atoms

const createItem = (param) => atom(param) // create `itemAtom`

const addItem = atom(
  null,
  (_get, set, itemAtom) => {
    set(itemsAtom, (prev) => [...prev, itemAtom])
  }
)

const interactiveItemsAtom = atom(
  (get) => get(itemsAtom).filter((itemAtom) => get(itemAtom).interactive)
)

Is this readable? We can change addAtom to accept param directly.

@garretteklof
Copy link

You can't create atom in render. If necessary, use useMemo.

Ah, duh.

That pattern is definitely simple / readable. Thanks for simplifying all my trials! One thing is that interactiveItemsAtom will trigger render even when other properties updated in itemsAtom? (not interactive)

@dai-shi
Copy link
Member

dai-shi commented Feb 21, 2021

One thing is that interactiveItemsAtom will trigger render even when other properties updated in itemsAtom? (not interactive)

Ah, that was the original concern. It's possible with some tricks.

const interactiveAtomCache = new WeakMap()
const getInteractiveAtom = (itemAtom) => {
  if (!interactiveAtomCache.has(itemAtom)) {
    interactiveAtomCache.set(itemAtom, atom((get) => get(itemAtom).interactive))
  }
  return interactiveAtomCache.get(itemAtom)
}
const interactiveItemsAtom = atom(
  (get) => get(itemsAtom).filter((itemAtom) => get(getInteractiveAtom(itemAtom)))
)

(There might be a better way.

@garretteklof
Copy link

Nice! I suppose that I could also use focusAtom again .. I like it better in this pattern. Thanks again for all the assistance on this. It's all been incredibly helpful.

@dai-shi
Copy link
Member

dai-shi commented Feb 21, 2021

Neither focusAtom nor selectAtom doesn't have caching mechanism. If that's ok, this should work?

const interactiveItemsAtom = atom(
  (get) => get(itemsAtom).filter((itemAtom) => get(atom((get) => get(itemAtom).interactive)))
)

@garretteklof
Copy link

garretteklof commented Feb 21, 2021

Yep, that works nicely. The trick is wrapping it in its own atom; I'm creating a list of ids (from the list of atoms) and before I was map(itemAtom => get(itemAtom).id) which did trigger an update.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

4 participants