Working on a reason-react
application is an absolute delight. The language is perfectly suitable for writing the application's logic with reducers, especially with the latest version of ReasonReact
with simpler and more concise syntax for writing components with hooks.
But when you need to do DOM manipulations, use refs
on DOM elements, attach some event handlers or work with event objects, it gets less pleasant. There are not many resources available, it is difficult to get the types right and compiler errors are sometimes not very helpful.
In this article I want to show how to do all of the above without pain, while solving a very common problem: detecting a click outside of a DOM element.
The end result will be a useClickOutside
hook, which takes in a function to run when a click is detected outside of an element, and returns a ref
that you need to attach to that element. The source code is in my github repo with an example usage of the hook, so feel free to check it out directly if you just need a working solution.
Use case
There are quite a few reasons why you might want to detect clicks outside of an element. The most common is to hide an element when the user clicks outside of its area, like closing a modal, a dropdown, a notification etc. So here is a straight forward solution:
- Listen to the
onmousedown
event on the document, - In the event handler get the element that dispatched the event (event target),
- Check whether the target element is a descendant of the main element that needs to react on click outside using
Node.contains
, - Call the function if it is not within the main element.
Implementation
I am using the latest ReasonReact
version (>= 0.7.0) that allows using hooks, if you haven't used them already in ReasonReact
, I highly recommend checking out this article.
For the implementation we will use bs-webapi
with reason
bindings to the DOM API and a couple of react hooks (useRef
and useEffect
).
So let's embrace the OCaml
type system and dive right into the implementation.
Add dependencies
Install bs-webapi
:
npm install bs-webapi --save
and add it to the dependencies in bsconfig.json
:
"bs-dependencies": ["reason-react", "bs-webapi"]
Add event listener in useEffect
Let's start implementing the useClickOutside
hook by adding a mousedown event listener in useEffect
:
open Webapi.Dom;
let useClickOutside = (onClickOutside: Dom.mouseEvent => unit) => {
let handleMouseDown = (_) => ();
React.useEffect0(() => {
Document.addMouseDownEventListener(handleMouseDown, document);
// cleanup - unsubscribe on unmount.
Some(
() => Document.removeMouseDownEventListener(handleMouseDown, document),
);
});
}
Here Document.addMouseDownEventListener
and document
come from Webapi.Dom
.
We start listening to the mousedown
event on the document
inside useEffect
hook. useEffect0
means it has no dependencies and thus only runs once after the component is rendered the first time.
In order to unsubscribe from the event, we can return a "cleanup" function from the effect. In ReasonReact
the type signature of the function in useEffect
is (unit => option(unit => unit))
, so we need to wrap our cleanup function in Some
.
Working with refs
Now we define the handleMouseDown
function, which also needs to access a ref
to the main element which lets us determine the outside
area:
let elementRef = React.useRef(Js.Nullable.null);
let handleClickOutside = (elRef, e, fn) => ();
let handleMouseDown = (e: Dom.mouseEvent) => {
elementRef
->React.Ref.current
->Js.Nullable.toOption
->Belt.Option.map(refValue =>
handleClickOutside(refValue, e, onClickOutside)
)
->ignore;
};
This looks cryptic ... What we are doing here:
- define a
ref
withuseRef
, initialise it withnull
, - access the underline value of the reference with
React.Ref.current
and convert it to option, - use
Belt.Option.map
to runhandleClickOutside
only if the ref value isSome
and return the result wrapped inSome
, otherwiseNone
, -
ignore
to disregard the result returned fromBelt.Option.map
.
I am using the fast pipe ->
here to apply an expression as the first argument to the functions. Here is a great article explaining how the fast pipe works if you are curious.
There is more info on working with refs in reason-react docs.
Check if element is outside
Great, almost done! Now we need to implement handleClickOutside
that will actually determine whether to call our custom function or not:
let handleClickOutside = (domElement: Dom.element, e: Dom.mouseEvent, fn) => {
let targetElement = MouseEvent.target(e) |> EventTarget.unsafeAsElement;
!(domElement |> Element.contains(targetElement)) ? fn(e) : ();
};
Here domElement
will determine the inside/outside boundary. It is important to mention that the mouse event in this case is not a react event (a.k.a. Synthetic
event), since we manually attached our callback to the document. In case of react mouse event you would use ReactEvent.Mouse.t
, in our case however we use Dom.mouseEvent
.
We will use Element.contains
to check whether the target element is a descendant of the domElement
. But here is a problem. This function takes in two parameters of type Element
, but the target element is of type EventTarget
, which strictly speaking, is not always an element and could for example be of type XMLHttpRequest
(mdn docs).
However, since we attached the event handler to a DOM element we know for sure it is an element and can use EventTarget.unsafeAsElement
to convert it to one.
Here is the link with the complete code of useClickOutside
hook.
Example usage
Here is how the hook can be used in the wild:
open ClickOutside;
[@react.component]
let make = () => {
let handleClickOutside = _ => {
Js.log("Click outside detected");
};
let divRef = useClickOutside(handleClickOutside);
<div ref={ReactDOMRe.Ref.domRef(divRef)} />;
};
I have created a simple dropdown component to show a real use-case scenario, source code on github.
I hope this article can help beyond this specific case of detecting click outside by providing some helpful tips and explanations when it comes to working with the DOM API.
Have you found anything that helped you? Or are you having trouble with DOM manipulations and refs while solving your particular case? Let me know by leaving a comment and we will figure it out :)
Top comments (0)