- Start Date: 2023-10-10
- RFC PR: (leave this empty)
- React Issue: (leave this empty)
This RFC proposes adding a utility to dispatch custom events in components. The RFC includes a proposal for a utility function called dispatchCustomEvent
: a utility that introduces some standards for emitting custom events from components, as well as a hook (potentially named useEventTargetImperativeHandle
) to create bound versions of the dispatchCustomEvent
function, to emit custom events that expose an imperative API.
A simple example of a component emitting a custom event
import { dispatchCustomEvent } from "react"; // 🤞🏼
const MyComponent = ({ onChange }) => {
React.useEffect(() => {
subscribeToSomeUserEvent((someArbitraryData) => {
dispatchCustomEvent(onChange, {
type: "change",
detail: { value: someArbitraryData },
});
});
}, []);
return <>{/*irrelevant*/}</>;
};
A simple example of consuming the data of the event
<MyComponent
onChange={(evt) => {
console.log(evt.detail.value);
}}
/>
This idea came to me after working in a component library for a couple of years and observing the state of widely used component libraries in open source (such as material-ui).
The issue is multi-faceted.
- The lack of a standard API for custom events leads to inconsistent and unpredictable APIs exposed by library developers. The larger the library, the more likely it is that some inconsistency will manifest - since larger libraries tend to have more developers, with different development styles.
- The lack of a standard API for custom events reduces interoperability of utility component or libraries that act on component/element events.
From my experience working in a component library, and observing popular open source libraries such as material-ui (and others), I've observed the following types be used for custom event handlers. This adds some friction when developing libraries that act on events.
type SlewOfCustomEventHandlers =
| (data: ArbitraryType) => void
| (data: ArbitraryType, originalEvt: React.SyntheticEvent) => void
| (evt: React.SyntheticEvent, data: ArbitraryType) => void
| (evt: React.SyntheticEvent) => void
| (evt: Omit<React.ChangeEvent, 'target'> & { target: { value: ArbitraryType } }) => void
| (params: { data: ArbitraryType, originalEvt: React.SyntheticEvent }) => void
| (params: {data: ArbitraryType} & React.SyntheticEvent) => void
| () => void
This type shows many of the types I've observed but I've ommitted types that include a native DOM event in place of the React.SyntheticEvent, which happen occasionally when components abstract events that are attached to the
window
,document
,body
elements, or events that are attached directly to DOM elements.
Lets say you have a component library that provides you with an API for adding validation to input fields.
The library may expose a hook that allows you to customize the validations for a given field, and provides you with a prop to monitor changes in the field.
const propsToMonitorChanges = useValidatedInput({
validations: {
required: true,
email: true,
// etc
},
});
const { onChange } = propsToMonitorChanges;
return <input onChange={onChange} />;
NOTE: In this example, I am presuming that the library supports both controlled and uncontrolled modes for input, hence the need for the generated
onChange
. When the value is controlled, the change is monitored using thevalue
prop, which should be passed to the hook.
This example would work fine without any issues.
But lets say the input
field must change from a simple input
, to a complex field widget, like a datepicker with a pop-up.
const propsToMonitorChanges = useValidatedInput({
validations: {
minYear: 2022,
// etc
},
using: DateValidator,
});
const { onChange } = propsToMonitorChanges;
return <DatePicker onChange={onChange} />;
A couple of things will need to change.
- ✅ the
value
supported by the field may not be astring
ornumber
(as it is in the native input). Maybe aDate
orTemporal.PlainDate
- ✅ the validators should change to something that can handle the new data type
- ❌ the
onChange
callback must handle the api exposed byDatePicker
At this point, there are a couple of options available to each group of developers (developers of the useValidatedInput
hook, and consumers of the hook) to handle the difference in the onChange
API.
- The developers of the hook can accept a "strategy" function to read values from the
onChange
event
❌ Interop issue exhibit 1. There has to be a strategy that is injected to properly handle the event. This is adding some code complexity
const propsToMonitorChanges = useValidatedInput({
getValueFromEvent: (customEvent) => getDateSomeHow(customEvent);
validations: {
minYear: 2022,
// etc
},
using: DateValidator
});
const {onChange} = propsToMonitorChanges;
return <DatePicker onChange={onChange} />
- The consumers of the hook can wrap the
onChange
event so that it has the shape of a native event
❌ Interop issue exhibit 2. Users of the validation library have to implement adapters for things to work, adding some code complexity.
const propsToMonitorChanges = useValidatedInput({
validations: {
minYear: 2022,
// etc
},
using: DateValidator,
});
const { onChange } = propsToMonitorChanges;
return (
<DatePicker
onChange={(customEvent) => {
onChange({
target: {
value: getDateSomeHow(customEvent),
},
});
}}
/>
);
Another option for the
useValidatedInput
hook is to reduce the API to just what is needed. If it only needs the value, have theonChange
callback only accept the value. However, this may not be practical if theonChange
callback must do other things besides just reading the value. (such as monitoring the timestamp of the event, or manage focus.)
The proposal is fairly simple: Expose a utility function to emit custom events. Custom events should resemble Web APIs where possible, and deviate if necessary to fit into a component oriented architecture.
Note the following definitions are meant to give a rough idea of the proposal, and to communicate the core of the proposal. The extended parts of the proposal will include additional types if necessary
Please read the comments explaining the role of each type
/**
* Leaning on the web standard for `CustomEvent`s, this is a subset of the native api.
*
* A few of the properties are false by default. This is solely to provide interoperability between
* events, as they will never be true in a component architecture.
**/
interface CustomEvent<DetailType> {
readonly type: string;
readonly detail: DetailType;
readonly cancelable: boolean;
readonly timeStamp: number;
readonly isTrusted: false;
readonly bubbles: false;
readonly composed: false;
}
type CustomEventHandler = <DetailType>(evt: CustomEvent<DetailType>) => void;
/**
* These are the properties needed to create and dispatch a custom event
*/
interface CustomEventInit<DetailType> {
type: stirng;
detail?: DetailType;
}
/**
* This utility function can be used when implementing components which emit custom events.
* It can be used in cases where the custom event serves as an abstraction of multiple different
* user interactions, or events that are not directly triggered by users but by some external
* event handled in the component (such as receiving a new message from a websocket).
*
**/
function dispatchCustomEvent<DetailType>(
handler: CustomEventHandler<DetailType>,
eventInit: CustomEventInit<DetailType>
): void;
In addition to the core part of the proposal, it would be good to allow library developers to expose a default event behavior that is cancelable.
Scenario:
Lets say a library developer implements a generic Form
component. The Form
component exposes an onSubmit
event prop, which is called when the data is submitted. The Form
component also triggers client-side field validations when the form is submitted.
Problem:
A user of the form library wants to perform some other validations, or some async process before the validations are triggered.
Leaning on the standard event APIs, we can support the preventDefault
method. dispatchCustomEvent
can accept a defaultBehavior
function, which can be used at the call site to define the default behavior of the event. Internally, dispatchCustomEvent
can defer the execution of defaultBehavior
until after the user-defined event handler is called, allowing the user-defined handler to cancel the default behavior.
The following type extensions will be needed
interface CustomEvent<DetailType> {
readonly cancelable: boolean;
readonly preventDefault(): void;
readonly isDefaultPrevented(): boolean;
readonly defaultPrevented: boolean;
}
interface CustomEventInit<DetailType> {
defaultBehavior?(): void;
cancelable?: boolean;
}
Library developers can configure a defaultBehavior, which will execute after the provided callback is executed.
import { dispatchCustomEvent } from "react";
const Form = ({ onSubmit, children }) => {
return (
<form
onSubmit={(evt) => {
// evt.preventDefault(); // Prevent native browser submit -- unrelated to proposal but necessary in component libraries. Just calling it out here to disambiguate between the two. The original preventDefault is irrelevant for purposes of this proposal
dispatchCustomEvent(onSubmit, {
type: "submit",
cancelable: true,
defaultBehavior: () => {
performValidationsOnFields(); // Default behavior that is cancelable
},
});
}}
>
{children}
</form>
);
};
Consumers of the form library can prevent the default behavior with the familiar preventDefault
method, then wrap the validations behavior however they need.
<Form
onSubmit={(evt) => {
evt.preventDefault(); // Prevents field validations
someAsyncProcess().then(() => {
someRef.current.performValidations();
});
}}
>
{/* Form fields omitted */}
</Form>
Following the scenario above, notice that in the example where the validation logic is wrapped by the consumer of the Form
component, I used some arbitrary ref reference (so to not imply that the ref has to come from anywhere in particular). However, it may be practical or beneficial to allow events to expose an imperative API. This can reduce the need for refs in some cases, where you just need to perform some imperative action during the event. For example, in native events, we can do things such as evt.target.focus()
. Following this, it may be useful to allow exposing an imperative API in custom events, where component developers can expose an abstract API for performing some action in the event.
The scenario for this example builds on top of the scenario for "Additional considerations 1: Form component with validation behavior on submit.". As showed in the proposed solution for the problem, the developer has to access some ref to perform the validations after some async process.
Another example is needing to move focus to some element abstracted by a component.
Scenario:
Building on the Form example, lets say there is a requirement to focus
on the input with errors when the validations are performed.
Problem:
The Form
component may already have all the ingredients and information to know which field needs to be focused (including a ref to the field). It would be practical if it can expose a simple API to focus on the relevant field. Otherwise, it must communicate enough information for the caller to be able to move focus to the field, thus creating the posibility for increased code complexity, or the need to manage more refs.
A simple extension of the core proposal that address this issue is to extend the CustomEvent
api to support custom imperative APIs. It feels intuitive to build upon the DOM event standard of setting the target
property.
I can see this is potentially a controversial part of the proposal. (More on this in the Drawbacks section). The thinking behind this choice is the following:
-
Custom events are partly intended to improve component encapsulation, while also maintaining some of the familiar structures established in the DOM. In the DOM, the
target
property is theEventTarget
that triggered the event. Adopting the models in the DOM, it makes sense to think of the component as the event target of a react custom event. As the event target, the component can be accessible viaevent.target
; However, when the paradigm of a component changes from declarative to imperative, we refer to the component as it's exposedref
, thus it seems reasonable thatevent.target
gives access to theref
of the component. -
This choice can facilitate improving interoperability of libraries, as it makes it trivial for library developers to expose APIs such as
event.target.value
orevent.target.focus()
<Form
onSubmit={(evt) => {
if (someCondition) {
evt.preventDefault(); // Prevent validations
performSomeAsyncAction().then(() => {
const hasValidationError = evt.target.validateFields(); // perform default validations
if (hasValidationError) {
evt.target.focusOnFieldWithError(); // Focuses on first field with errors
// or evt.target.focus()
}
});
}
}}
>
{/* Fields omitted*/}
</Form>
The follow type extensions will be needed for this part of the proposal
/**
* A new generic type is needed
*/
interface CustomEvent<DetailType, TargetType> {
target: TargetType;
}
/**
* A new generic type is needed
*/
interface CustomEventInit<DetailType, TargetType> {
target?: TargetType;
}
For this part of the proposal, there are a couple of choices that can be made depending on the mental model the react team feels is more appropriate to adopt.
The choices for the models that I can identify are the following:
- The target refers to the component emitting the event.
- The target refers to some abstract EventTarget that is not inherently coupled to the identity of the component.
Depending on which of these choices are made, the API for configuring the target can be expressed in a "closed" fashion (where some assumptions are made automatically) or "opened" where it is completely arbitrary and up to the developer to make a deliberate choice of setting the target.
In the closed API, some assumptions can be tied to the generation of component refs. The benefit may be along the lines of consistency accross custom event implementations, but the trade-offs will likely be manifested as increased complexity of implementation, or blurring the porpose of the existing APIs.
In this example, dispatchCustomEvent
is provided by the useImperativeHandle
and the target is automatically bound.
const MyComponent = React.forwardRef(({ onCustomEvent }, ref) => {
const { dispatchCustomEvent } = useImperativeHandle(ref, () => {
performSomeComponentAction: () => {};
/* generate handle*/
});
React.useEffect(() => {
subscribeToSomething(() => {
dispatchCustomEvent(onCustomEvent, {
type: "custom-event",
});
});
}, []);
});
Its likely cleaner to introduce a new API instead of changing useImperativeHandle
. The example will look the same except with a different hook name
import { useEventTargetImperativeHandle } from "react";
const MyComponent = React.forwardRef(({ onCustomEvent }, ref) => {
const { dispatchCustomEvent } = useEventTargetImperativeHandle(ref, () => {
performSomeComponentAction: () => {};
/* generate handle*/
});
React.useEffect(() => {
subscribeToSomething(() => {
dispatchCustomEvent(onCustomEvent, {
type: "custom-event",
});
});
}, []);
});
An opened API will probably be much simpler to implement in react, but increases the chances of the event target beeing inconsistent across custom events of a single component. Additionally, some extra care must go into merging the given ref and the ref managed by the component.
import { dispatchCustomEvent } from 'react';
const MyComponent = React.forwardRef(({ onCustomEvent }, ref) => {
// Omitting the implementation of the `useOptionalRef` hook because it is irrelevant
// The purpose is to show that some care must go into merging the given ref
// and the ref used in `dispatchCustomEvent`, as the `ref` provided may be null or undefined
const targetRef = useOptionalRef(ref)
useImperativeHandle(targetRef, () => {
performSomeComponentAction: () => {}
/* generate handle*/
})
React.useEffect(() => {
subscribeToSomething(() => {
dispatchCustomEvent(onCustomEvent, {
type: 'custom-event'
targetRef
})
})
}, [])
})
My personal preference is "Option 2: new hook for defining imperative handle that emits custom events" for the following reasons.
- It seems it would be relatively simple to implement
- Provides a clear pattern for binding the target, without a need of managing merging of refs
- Removes the need to keep and pass refs around (as the open API does).
- Avoids changing existing APIs. Changing
useImperativeHandle
to have a return type may be a breaking change (depending on how you look at it). In the ideal case production code is not affected, but developers may start to get typiging errors or lint errors if they do not assign the return type to a variable.
It was difficult expressing the design ideas without expressing the types in incremental parts. However, I will choose to provide the type definitions in a centralized location here for reference.
I've also tried to break it up into independent features
//// CORE FEATURES
interface CustomEvent<DetailType, TargetType> {
readonly type: string;
readonly detail: DetailType;
readonly cancelable: boolean;
readonly timeStamp: number;
readonly isTrusted: false;
readonly bubbles: false;
readonly composed: false;
}
type CustomEventHandler<DetailType, TargetType> = (evt: CustomEvent<DetailType, TargetType>) => void;
interface CustomEventInit<DetailType, TargetType> {
type: stirng;
detail?: DetailType;
}
interface DispatchCustomEvent {
<DetailType, TargetType>(handler: CustomEventHandler<DetailType, TargetType>, eventInit: CustomEventInit<DetailType, TargetType>): void
}
const dispatchCustomEvent: DispatchCustomEvent;
//// EVENT DEFAULT BEHAVIOR / CANCELABLE FEATURES
interface CustomEvent<DetailType, ValueType> {
readonly cancelable: boolean;
readonly preventDefault(): void;
readonly isDefaultPrevented(): boolean;
readonly defaultPrevented: boolean;
}
interface CustomEventInit<DetailType, ValueType> {
defaultBehavior?(): void;
cancelable?: boolean;
}
//// IMPERATIVE EVENT API FEATURES
interface CustomEvent<DetailType, TargetType> {
target: TargetType;
}
interface CustomEventInit<DetailType, TargetType> {
target?: TargetType;
}
// Assuming the closed API described above
type BoundCustomEventInit<DetailType> = Omit<CustomEventInit<DetailType, any>, 'target'>
type BoundDispatchCustomEvent<DetailType, TargetType> = (
handler: CustomEventHandler<DetailType, TargetType>,
eventInit: BoundCustomEventInit<DetailType>
): void
type ComponentEventTarget<DetailType, TargetType> = {
dispatchCustomEvent: BoundDispatchCustomEvent<DetailType, TargetType>
}
function useEventTargetImperativeHandle<DetailType, TargetType>(
ref: React.Ref<TargetType>,
imperativeHandleFactory: () => TargetType,
dependencies: any[]
): ComponentEventTarget<DetailType, TargetType>
-
User land solution. Because this doesn't require changes to the core of react, this can be easily handled by an open source library. However, it is less likely to achieve the level of adoption I would like to see with this, as the chances of multiple open source libraries implementing slightly different patterns is still there. The problem is less in the complexity of the implementation, but in the lack of a standard, which leads to the inconsistencies I mentioned in the problem statement.
-
RFC: EventTarget has some overlaps with this RFC. However, I believe this proposal align with the react conventions for custom events (simple callbacks) and is simpler to implement because it does not rely on props requiring special treatment by react.
I may lean on the react team for a more thorough exploration of the drawbacks. These are some of the drawbacks I was able to identify.
- The API for emitting custom events adds some complexity to something that is relatively simple for react users: Calling a callback function.
- Since it is an opt-in feature and it may be hard for casual users of react to see the benefit of using this API, it may go unused in the majority of cases.
- As I mentioned before, this can be implemented in the user space. However,
react
is positioned to have a broader and more positive impact than any open source library in terms of adoption. - Library developers that choose to adopt these new APIs will have to consider potential breaking changes. Older unmaintained libraries will continue to expose inconsistent APIs.
- This solution is attempting to solve an inconsistency problem, but since it is an opt-in solution, it will never completely get rid of inconsistency in library APIs.
For react this would (or should) not be a breaking change.
For library developers choosing to implement a custom event API that aligns with react and this proposal, they will likely have to introduce a breaking change in their libraries, unless they implement some strategy for maintaining backwards compatibility (like emitting the legacy and new events and exposing different props.)
Thankfully much of this proposal builds on existing web standards and naming conventions, which may make it easy to teach. However, recognizing that there will be differences between the web standards and the APIs expressed here, I'd imagine that we would call this "React Custom Events".
I will step away from the problem for a few and revisit this if I encounter any doubts 😅