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

[RFC] Add imperativeHandle construct #70

Open
ThaNarie opened this issue Mar 2, 2022 · 3 comments
Open

[RFC] Add imperativeHandle construct #70

ThaNarie opened this issue Mar 2, 2022 · 3 comments
Assignees
Labels
Enhancement New feature or request Help Wanted Extra attention is needed Public Api Types Issue is related to the type system
Milestone

Comments

@ThaNarie
Copy link
Contributor

ThaNarie commented Mar 2, 2022

In React: https://reactjs.org/docs/hooks-reference.html#useimperativehandle

Usage in Muban: As a last resort when using props doesn't work.

Example: setting the focus of an input element wrapped in a component.

Questions to answer:

  1. Child – In React the imperativeHandle is attached to the passed ref using forwardRef. In Muban we have nor need either of those currently. So how do we "expose" the handle to the outside?
  2. Parent – In React, the imperativeHandle will be attached to the passed ref, and can be used after the first render (after the useEffect). In Muban, we we either have to pass something (like a built-in ref prop), or we can "retrieve" the handle from the refComponent (just like we extract props).

Suggestions

1. using bindings

Adding a custom forwardRef param that can be passed to a bind.

// parent
const handleRef = ref<TypeOfHandle>();

watch(handleRef, () => {
  handleRef.value.focusInput();
});

// this needs to be a custom propType so we can pass a `ref` (instead of a `computed` that gets `unwrapped`
bind(refs.child, { forwardRef: handleRef });
// child
// depending on timings, the forwardRef might not be passed from the parent yet
// so internally we would have to wrap it inside a computed, and double-wrap it to not auto-resolve
setup({ forwardRef }) {
  // this will attached it to the `forwardRef` `ref`
  useImperativeHandle(forwardRef, {
    focusInput() {
      // implementation details
    }
  });
}

2. using the refComponent

This setup is a bit more integrated, and doesn't require a user-defined ref to pass along.

The (only?) downside is that it doesn't let you watch for when the "imperativeHandle" is available. Although in almost all cases I don't see this as an issue, as you won't use them immediately in the setup function anyway, but rather on different async events.

// parent
setup({ refs }) {
  // Depending on the timing, this might not exist yet.
  // Otherwise the `useMounted` might need to be used, or any other "async handler".
  // For lazy components this might be more unclear when it can be used?
  refs.child.component?.imperativeHandle?.focusInput();

  // or even just:
  refs.child.component?.focusInput();
}
// child
setup() {
  // this gets registered internally
  useImperativeHandle({
    focusInput() {
      // implementation details
    }
  })
}

2.5. providing the useImperativeHandle from the setup params, so it can be typed

This setup has the same "usage", but a more integrated "register", to better make used of types (see further below).

// child
setup({ registerImperativeHandle }) {
  // this gets registered internally, and it type matches that of the component
  registerImperativeHandle({
    focusInput() {
      // implementation details
    }
  })
}

Challenges

Timing

Since the "imperativeHandle" contains runtime code that could interact with objects inside the setup function, it must be "registered" there as well.

For 1️⃣ it would make life easier of the parent was executed before the child, but that's not the case. So both the user and the framework implementation is more cumbersome.

For 2️⃣ it would make life easier if the setup function of a child component is called before the setup of a parent component, which should be the case. So to me, that sounds like the best option for multiple reasons – user and framework simplicity.

Typing

Most of the component's types can be inferred/extracted from the object definition (the refs and props). But the "imperativeHandle" would be defined in the setup at "runtime", and cannot be used automatically.

For this we would most likely need to provide additional types to the defineComponent and the useImperativeHandle.

type FooHandle = {
  focusInput: () => void;
};

// specify here to add to the component type
const Foo = defineComponent<FooHandle>({
  setup({ registerImperativeHandle }) {
    // this is already typed, so the passed object must match the `FooHandle` specified for the `defineComponent`
    registerImperativeHandle({ ... });
  }
});

Naming

Having imperative in there is probably good, since it "escapes" into the imperative programming model. Not sure if handle is a good fit here, since that might be more specific to how React attaches that to a passed ref.

But I can't think of anything better a.t.m., so registerImperativeHandle is currently the best option.

Implementation details

  • In React, the object you pass to useImperativeHandle is a function. Most likely this is because the render function is executed multiple times, and you don't want to re-create these objects every time. – In Muban, the setup gets only executed once, and individually for each component, so passing the object directly is totally fine.

  • We must think of scenarios where the ref itself is lazy / optional, and the component might not exist yet. But since the imperative handle is attached to the component, as soon as the component exists, the handle should also exist. However, for lazy/optional components, the timing of where we assign the component to the internal ref (which could trigger a watch) should ideally be after the setup of the new child component is called.

  • We need to make sure that types are properly resolved. defineComponent already has some generics, so they need to "shift" to make room for this one one, and also probably should get default values, since you cannot just pass 1 of the non-optional ones – you would have to pass all of them.


@psimk @jspolancor @larsvanbraam @ThijsTyZ any thoughts, preferences, opinions?

@ThaNarie ThaNarie added Enhancement New feature or request Help Wanted Extra attention is needed Public Api Types Issue is related to the type system labels Mar 2, 2022
@ThaNarie ThaNarie added this to the alpha.33 milestone Mar 2, 2022
@ThijsTyZ
Copy link
Collaborator

ThijsTyZ commented Mar 8, 2022

Would it be an option to have the imperative handle defined outside the setup function in its own node in the defineComponent? This would make it more explicit and also makes sure you can only define this once (because in the setup you could call the registerImperativeHandle twice).

export const MyComponent = defineComponent({
  name: 'my-component',
  props: {
    // ...
  },
  refs: {
    inputField: refElement('input-field'),
    // ...
  },
  imperativeHandle({ props, refs }) {
    return {
      focus() {
        refs.inputField.element?.focus();
      },
    }
  },
  setup({ props, refs }) {
    // ...

    return [
      // ...
    ];
  },
});

@ThaNarie
Copy link
Contributor Author

ThaNarie commented Mar 8, 2022

Technically yes, but it would limit the usage of it. You might want to interact with other variables from your setup function, which you can't access.

So no, that's not something we want to do..

@ThijsTyZ
Copy link
Collaborator

Yes, you are right. You need access to all variables in the setup function

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Enhancement New feature or request Help Wanted Extra attention is needed Public Api Types Issue is related to the type system
Projects
None yet
Development

No branches or pull requests

3 participants