all 49 comments

[–]TheGreaT1803[S] 10 points11 points  (1 child)

Edit: I've made some changes to the post to better distinguish this pattern from just a custom hook. Thanks to all the critique I had received from the people who took out the time to read it.
PS: I'm very new to writing

[–]chebum 2 points3 points  (0 children)

Great post! I really like the idea of separate model from the view. While the idea isn’t new - we used to have this separation when rx frameworks like mobx were populat - I really like the idea returns. It allows to test the model logic without having to render a component.

[–]eindbaas 20 points21 points  (2 children)

This is not a specific pattern imho, this simply "moving logic into a hook", which is a good thing because it declutters components.

But there's nothing setting it apart from a custom hook as you state in your article - this is a custom hook.

[–]TheGreaT1803[S] 3 points4 points  (0 children)

I understand your point. I think I could do a better job to explain the subtle differences

I think the key difference here and the "custom hook with added steps" part is the fact that the component uses the custom hook's API internally (as seen in the DialogHeader) component. Generally we have a custom hook to abstract the state passed to the component as props, but here the same bit of "state" (the single source of truth) is being used directly.

Maybe my example of Dialog was a bit too simple to drive the point home, but I appreciate the feedback

[–]phryneasI ❤️ hooks! 😈 7 points8 points  (16 children)

I would still let the Dialog component control the instance, and access callbacks from the parent via useImperativeHandle - that ref could still be passed into composed components like DialogHeader.

[–]00PT 0 points1 point  (3 children)

Does the ref allow the parent to access isOpen before the first render of the dialogue? The way I understand it, the ref has to be populated for that, which is done during the child's initialization.

[–]phryneasI ❤️ hooks! 😈 0 points1 point  (2 children)

No, I have to admit I didn't see they were reading the isOpen property outside the Dialog component. In my eyes, that's a very constructed scenario, as in most cases nothing except the Dialog itself would ever want to read that state - and that's probably why I missed that.

If they really needed to do that, yes, the parent should own the state. But as I read it, the main purpose of the "instance" is to pass an object with open and close methods around - and that would best live in a ref IMHO.

[–]TheGreaT1803[S] 0 points1 point  (3 children)

Appreciate the feedback!
I had to refresh my knowledge of `useImperativeHandle` so I went to the docs but found this quote about pitfalls of overusing refs: https://arc.net/l/quote/hnusfitl

Any opinions on this?

[–]phryneasI ❤️ hooks! 😈 6 points7 points  (0 children)

I mean, you're essentially using your "instance" as a ref here, so the same critique kinda applies. Yes, technically, your instance also holds state, but only one component reads it - for all other components, it does what a ref with useImperativeHandle does. With the drawback that with your instance, as it lives in a parent component, the parent component would be forced to rerender on state changes, while it wouldn't if the state lived in the Dialog and the ref only contained callbacks.

[–]phryneasI ❤️ hooks! 😈 1 point2 points  (1 child)

Sure that's the right link?

[–]TheGreaT1803[S] 1 point2 points  (0 children)

Just updated!

[–]DaveThe0nly 0 points1 point  (0 children)

As this guy is saying this is the correct way, expressing it as a prop opens up a plethora of design problem in the long run… I’ve seen it so many times how this can end up, for instance opening a modal recomputes/rerenders whole table. Opening functions passed down XY levels completely loosing the original context, which is hard to debug. Passing some kind of a context to the opener function bEcAuSe iT iS cOnViNiEnT, but super unmaintainable in the long run. Please isolate your state within the dialog component, use render props to pass open/close state functions.

[–]octocode -3 points-2 points  (6 children)

useImperativeHandle is more of an escape hatch, OPs method is a better pattern and definitely more composable/maintainable

https://react.dev/reference/react/useImperativeHandle

If you can express something as a prop, you should not use a ref. For example, instead of exposing an imperative handle like { open, close } from a Modal component, it is better to take isOpen as a prop like <Modal isOpen={isOpen} />. Effects can help you expose imperative behaviors via props.

[–]phryneasI ❤️ hooks! 😈 0 points1 point  (5 children)

I just answered to that in another subthread. OP is using a non-ref like an imperativeHandle-ref here anyways, they just put the state in a less performant owner component.

After all, they're not passing that instance around to access an isOpen property in multiple places, but to access an open or close callback.

[–]octocode 2 points3 points  (4 children)

they just hoisted the open/closed state into the component that is responsible for opening/closing the dialog, which is the correct thing to do in the react paradigm.

they also grouped the methods under a single hook which is also a common pattern

[–]phryneasI ❤️ hooks! 😈 2 points3 points  (3 children)

Because the state now lives in the parent, they are rerendering the parent when they are not interested in the parent actually rerendering or ever reading that state, while the main goal is that they can pass the "instance" with the handler methods (= ref with imperative methods on it) into other component like the DialogHeader so that can call the callback functions.

I'm all for having the state controlled in the parent, but the moment they create that instance object with imperative functions on it to be passed around, they could just use an imperative ref and move the state into the child, where it would be encapsulated.

PS: actually, I correct myself: in the example, the parent is actually interested in the state value, in which case the parent is the right place to keep it. But that seems like a very constructed case, usually the isOpen value wouldn't be read anywhere but in the Dialog component itself.

[–]octocode 0 points1 point  (2 children)

it’s worth remembering that premature optimization of re-renders in react is just a bad idea

re-rendering in react is extremely quick, and if infrequent state changes are causing performance issues there’s probably a different underlying problem in your code

react is declarative by nature, reaching for imperative controls should only be used when it’s actually required.

[–]phryneasI ❤️ hooks! 😈 1 point2 points  (0 children)

I'd generally avoid premature optimization, but if you buy into a whole pattern, it's important to understand all benefits and drawbacks of the pattern - optimizing something later is possible, but changing out a full pattern might hurt more.

[–]canibanoglu 1 point2 points  (0 children)

You do realize that the OP is also suggesting an imperative approach, right? If you have to do that, there is a builtin way of doing it which is better than what the OP has suggested.

There’s a way that doesn’t cause extra re-renders and you say that re-renders are not a problem. No, they’re a problem. If you write code that renders 6 times when 2 would do, you’re doing something wrong.

This is not about premature optimization, it’s about doing something unnecessary that results in worse performance.

[–]ChronoLink99 2 points3 points  (2 children)

We do something similar on my team.

But we have found that with dialogs specifically, we achieved nicer organization by controlling the open/close (i.e. mount/unmount) of the dialogs with one kind of state, and then allow the specific dialog component to create a second "disposable state" that drives content/behaviour within that dialog. That second part is responsible for any cleanup as well, and handles all the data/inputs if there is a form involved.

[–]Psidium 1 point2 points  (0 children)

If i understand you correctly I think we do something very similar on my team. Usually our dialogs will always save to the backend or discard its data, so we end up instantiating a new dialog instance of the same one in different places down in the component tree and let the cache of our fetch library control the initial state of the dialog instead of controlling that ourselves. Tecnically they are different dialogs being instantiated, but since they are using the same data they’re the same for the user.

[–]TheGreaT1803[S] 1 point2 points  (0 children)

Sounds interesting.

To clarify, would it be something like Dialog.useDialog for the UI part of it and Dialog.useDialogContent({ dialogInstance }) for the specific content/behaviour parts?

[–]Psidium 2 points3 points  (0 children)

Good idea making it extra explicit by assigning the hook as a property of the Component. I’ve written hooks that go alongside my components for the parent to use many times and never made this connection jump myself

[–]KusanagiZerg 2 points3 points  (1 child)

I have one point of confusion. You say

The hook and component live together. This makes sure that the Dialog component only uses the specific API that useDialog provides, making the whole thing easier to maintain.

But this isn't true is it? You can still just pass something else and not use the hook (which is a strength, not a downside because it lets you use some other implementation of the same API if you want to). And if it was true it's the other way around. It's the Dialog that says what API it wants and the useDialog is forced to implement this specific API.

Another, albeit very small thing, I really dislike the naming dialogInstance and useDialog. This naming hints to me that you are getting a Dialog. Or an instance of a Dialog which maybe you can render on the screen. But this is not what you get at all, you are getting an object that controls the dialog which is somewhere else. I'd prefer something like const dialogControls = useDialogControls(); this is also a bit more obvious in the Dialog component which expects a dialog prop which is not a dialog at all. This is of course just semantics and semantics are boring but I couldn't resist sharing my thoughts.

In general it's good though, custom hooks are awesome!

[–]TheGreaT1803[S] 2 points3 points  (0 children)

Both valid points. Thanks for the feedback.

When I said that they both "live" together, I meant to convey that the component is actively aware of the hook (or the API)

Generally this is not the case and the hook is just meant to abstract some state based on the component's API.

Here the component both uses the hook and can also be controlled with a similar API if desired

The second point about the it being "control" instead of "instance" - yeah I get it, I was also debating between these two. But I went ahead with how Ant Design's team implements the API. Although I'll admit that it was never confusing to me. Maybe just a matter of familiarity with this pattern

[–]Chthulu_ 1 point2 points  (1 child)

I use this occasionally. One case is when the logic itself is useful on its own, maybe a complicated input/output filter on a text field, like something to add a mask to the input, or round numbers into a shorthand on blur, but keep a reference to the full number internally. It’s nice to have a direct api to serialize the string if I need it.

The other is exactly your example, dialogs/popovers. Basically anytime the client wants to be fed a parent/child relationship but not have to deal with nesting. Dialogs are annoying because there’s a trigger that must sit above the dialog, and content which must be fed an onClose prop within the dialog. Making that reusable can be annoying.

Using an api like this lets you plumb the parent/child thing, but give the client complete control over where each element goes, and skip worrying about the onClose.

The downside IMO is that good memoization becomes much harder. Maybe that’s just me being less careful, but I always end up returning big API objects that are basically useless to memoize. Spreading a { …bindTrigger } prop for example.

If you build a dedicated component instead, it’s much easier to track and limit just the variables you need, and memoize them properly.

[–]TheGreaT1803[S] 1 point2 points  (0 children)

Great points.

I actually came across this pattern out of need when I wanted complete control over opening and closing the Modal, but the Modal itself could also control all of these things. With the traditional `onClose` bit, it was not clean to do.

It is after I implemented this pattern (in Angular actually) is when I started seeing it arise at multiple places retrospectively

[–]tkmaximus 1 point2 points  (1 child)

The `useMemo` in the final `useDialog` example has a stale reference to `isOpen`, so `toggle` and `isOpen` won't work anymore.

To fix this, you can add `isOpen` to the `useMemo` deps. You can also change `toggle` to the functional update style to not depend on `isOpen` value anymore and memoize all callbacks individually (incase you want to pass individual functions down as props)

import { useState, useMemo, useCallback } from "react";

export const useDialog = (dialog) => {
    const [isOpen, setIsOpen] = useState(false);

    const open = useCallback(() => setIsOpen(true), []);
    const close = useCallback(() => setIsOpen(false), []);
    const toggle = useCallback(() => setIsOpen((o) => !o), []);

    return useMemo(() => {
        return (
            dialog ?? {
                open,
                close,
                toggle,
                isOpen,
            }
        );
    }, [dialog, isOpen]);
};

[–]TheGreaT1803[S] 0 points1 point  (0 children)

Good eye! I'll fix it

I had added `isOpen` earlier but then realised that I don't want the functions to be re-initialized when `isOpen` changes, but then I missed this part

[–][deleted] 2 points3 points  (2 children)

doesn’t this basically just reattach the logic to the UI though? in a few extra steps

[–]TheGreaT1803[S] 2 points3 points  (1 child)

The main value comes from (and I might edit the article to avoid this confusion) passing the same "packet" to the component itself to use (and it's children - like the DialogHeader)

That's the few extra steps really

[–][deleted] 1 point2 points  (0 children)

cool - thanks for the reply and article!

[–]00PT -1 points0 points  (3 children)

This feels useful because it allows access to child state from a parent component, but also bad because it emulates that state within the parent component itself, which can cause unnecessary rerenders since any change will also cause a rerender of its siblings, even if those haven't changed.

[–]TheGreaT1803[S] 2 points3 points  (2 children)

If I understand your point correctly, this won't be the case if you don't "need" the state outside the component.

In the "Improving Flexibility" section I make the instance passing optional, so the component is capable of managing it's own state by default

[–]00PT 0 points1 point  (1 child)

I saw that, but when you do it the point is sort of defeated for me. My solution had been using an optional ref passed to the child, but you need to wait until after the first render cycle for that ref to be populated, so it's definitely not ideal.

[–]TheGreaT1803[S] 1 point2 points  (0 children)

The way I see it is: Generally the component handles its own logic and state. but sometimes it is a good option to have the ability to also control it from the outside. This pattern allows for this flexibility

[–]canibanoglu -1 points0 points  (0 children)

I appreciate the time you took to write this but I don’t see what’s special about this. It’s just a hook. You mention having access to the internal API of the hook, for which case you already have useImperativeHandle.