loading...

ReasonReact context explained in action

margaretkrutikova profile image Margarita Krutikova ・5 min read

Context in react is designed for sharing some global data between components located at different levels of the component tree. It allows to avoid passing props all the way down to those components ("prop-drilling") while still updating them whenever the value in context changes.

Worth noting that it is recommended to use context for low-frequent updates (quote by Sebastian MarkbΓ₯ge), due to a possible performance impact because of to the way react finds subscribers to the context value. This topic would require its own article (or perhaps a book?), and I will not touch upon it here and instead focus on a practical example of using context for seldom updates in a react application with ReasonML.

What are we building

We are going to build a feature with log in/log out, where we will put information about the user in context, so that we can access it from anywhere in our app and customise it depending on whether the user is browsing anonymously or not. The source code in the article is in this repo, and a link to the mini-app with this feature is here.

There are many bits and pieces that have to be wired up together in order to get benefits and all the convenience react context provides, especially in a strongly-typed environment with ReasonML, but it is definitely worth.

I will go through the steps needed to connect everything together and we will end up with a simple hook that allows to read the user data from context and dispatch and action to update it from any component, like this:

let (user, dispatch) = UserContext.useUser();
let handleLogIn = () => dispatch(UserLoggedIn(userName));

switch (user) {
  | Anonymous => /** display login form */
  | LoggedIn(userName) => /** say hi to the user! */
};

Scroll down to learn how πŸ‘‡

Create provider and context

We will start with these steps:

  1. Create context,
  2. Create provider component,
  3. Create reusable hook to access context value.

We need to know whether the user using our app is anonymous or logged in, and what actions can change this, so let's start with a few types:

/** Types.re */
type user =
  | Anonymous
  | LoggedIn(string);

type userAction =
  | UserLoggedIn(string)
  | UserLoggedOut;

LoggedIn will hold user name, but can be any other type with more user data. We will use userAction when implementing a reducer for our user state.

Now let's create context and reusable hook to access the context value, in a file UserContext.re:

/** initial value is Anonymous */
let context = React.createContext(Anonymous);

/** hook to easily access context value */
let useUser = () => React.useContext(context);

This is very similar to how you would do it in JS. Now let's create context provider in a file UserProvider.re

/** UserProvider.re */
let make = React.Context.provider(UserContext.context);

/** Tell bucklescript how to translate props into JS */
let makeProps = (~value, ~children, ()) => {
  "value": value,
  "children": children,
};

What is that makeProps for and why can't we just create a normal component with [@react.component] and make? The question I asked myself many times until I got tired and dug into it and found out πŸ€¦β€β™€οΈπŸ™ƒ

Remember how we always have named arguments for props in our reason components, like ~id or ~className? JS doesn't have such a feature, and all regular JS components just want to have props as an object. So how does it compile to valid react components in JS?

That's what the attribute [@react.component] is for. It will generate a function called makeProps, that transforms those named arguments into a JS object to be used as props in the JS compiled component.

React.Context.provider already generates a react component, that uses props as a JS object, but we want to use it as a reason component with named args. That's why we create makeProps by hand and it will tell bucklescript how to translate our named args into a JS object, consumed as props by the JS component. And in order to create an object that will compile cleanly to a JS object, we use bucklescript Object 2 bindings, that look like this:

{
  "value": value,
  "children": children,
}

So we are basically doing the job of [@react.component], but luckily it is not much, since the provider just needs a value and children πŸ˜….

We can now use our provider component like <UserProvider...> since we followed the convention of having two functions make and makeProps in a file UserProvider.

Update value in context

Now, we want to use our Provider component and give it the user info, that we can update when the user logs in or logs out.

The important thing to understand here, is that if we want to update the value in context and propagate the update to subscriber-components, the value needs to be on the state of some component. This component needs to render the provider component with the value from its own state.

Let's call it Root component:

/** Root.re */
type state = {user};

/** user and userAction defined in Types.re */
let reducer = (_, action) =>
  switch (action) {
  | UserLoggedIn(userName) => {user: LoggedIn(userName)}
  | UserLoggedOut => {user: Anonymous}
  };

[@react.component]
let make = () => {
  let (state, dispatch) = React.useReducer(reducer, {user: Anonymous});

  <UserProvider value=state.user>
    <Page />
  </UserProvider>;
};

Cool, now whenever the value in context changes, components using useUser will be updated with the new value! Wait, the value actually never changes.. oh no! 😯

Let's give our components possibility to update user data via context. We could pass the update function down as props, which will be back to prop-drilling approach, but a more fun way is to include dispatch in the context value itself.

Pass dispatch in context

Let's pass our dispatch along with user as context value. Knowing that dispatch accepts userAction and returns unit, we can modify the type of context value in UserContext.re:

/** UserContext.re */
type dispatch = userAction => unit;
type contextValue = (user, dispatch);

let initValue: contextValue = (Anonymous, _ => ignore());
/** no changes when creating context */

and the root component:

/** Root.re */
let make = () => {
  let (state, dispatch) = React.useReducer(reducer, {user: Anonymous});

  <UserProvider value=(state.user, dispatch)>
    <Page />
  </UserProvider>;
}

Use context value via hook

And now the reward I promised in the beginning, an easy to use and convenient hook. I will just repeat it here once more, because it is cool:

let (user, dispatch) = UserContext.useUser();
let handleLogIn = () => dispatch(UserLoggedIn(userName));

switch (user) {
  | Anonymous => /** display login form */
  | LoggedIn(userName) => /** say hi to the user! */
};

Bonus: optimization techniques

Updating context value will cause subscribed components to re-render. In some cases we might want to avoid extra re-renders if we know they won't bring any updates to our UI. For example, if a component only needs to update user via dispatch, it won't be interested in any updates to the actual user data, but it will still re-render if the user is updated.

This can be solved by having the dispatch function in a separate context, which won't update, since dispatch is guaranteed to be stable. The other context will have the user data and will update the components that rely on it.

When the Root component itself updates (if its props are updated for example), it will recreate the tuple (user, dispatch) passed in context and cause subscribed components to update. This can be solved by using useMemo around the context value to make it stable.


We have now set up all we need to use context for storing and updating a small amount of global data in our reason-react application. We have also looked into some underlying mechanisms of how context works in react and how components are compiled in reason-react.

Have I missed something or made a mistake? Please let me know in the comments. Or just drop a line about how you are using context in your application! πŸ’¬

Posted on by:

margaretkrutikova profile

Margarita Krutikova

@margaretkrutikova

Software developer, Typescript by day, ReasonML by night

Discussion

markdown guide
 

This is a great, thorough explanation, thanks a lot! I also found context in Reason very confusing at first. Thanks also for explaining why you can't use the normal React component annotation as well, that feels like a really important point.

Another trick, that's slightly more involved, is putting dispatch in a separate context, all by itself. That way you avoid having to pass a tuple (which I think will make all components using the context re-render as it creates a new array -> new context value each render) and components that only dispatch stuff don't need to re-render when state changes.

I guess more or less the same thing could be achieved with useMemo as well. Anyway, this probably only matters in a few use cases, but I thought it was worth mentioning as I've struggled with issues related to that myself.

I look forward to reading more articles from you!

 

Thanks a lot for your comment!

You are totally right about memoization and having separate contexts for dispatch and actual value. It is a cool trick to avoid re-render of components that only need dispatch. Funny, I actually have those points as a comment in the source code, but I thought I would leave them out from the article πŸ™‚

I think I will add them in the end, since it is important people are aware of them in case they encounter one of those "few cases". Thanks for mentioning this info! πŸ‘

 

You didn't use the hook in the app after all that?!?!?

For other's who might want to actually use it, example, here

Great write up, @margaretkrutikova . Nice details and teaching. Since you dug into makeProps, why do we have to call it with a unit arg? As in

let makeProps = (~value, ~children, ()){}

instead of which fails

let makeProps = (~value, ~children){}

Thanks again and keep it coming. Peace to you.

 

Sorry, could you clarify what you mean by "you didn't use the hook in the app"? I did use it here.

That's actually a very good question. Usually you need to pass unit () in the end if you have optional labeled arguments. If you don't enforce this, the compiler won't know whether you want to partially apply arguments to your function (=curried function, and maybe pass that optional argument later?) or you actually want to omit that argument and call the function right away.

I assume, since all components can optionally receive key as prop, you have to explicitly pass that unit to make sure it is going to be called and not partially applied. You can see here in the docs that reason component will generate makeProps like this:

[@bs.obj]
external makeProps: (~name: 'name, ~key: string=?, unit) => {. "name": 'name} = "";

So I think it will always have that optional argument key. I hope this makes sense!

 

Indeed you did. In your post you had it assigned to a function and looked for that in the code. You ended up using it directly in form onSubmit. github.com/MargaretKrutikova/pract...

Nice.

Optional key. Of course. I was wondering which of these props is optional. Tack, Madame. You are appreciated!

 

Great article. Just a little question. Are we actually initializing user two times? Once in root and once in the context?

 

Thank you for your feedback!
That's a great question, I haven't really thought about it myself. It appears that the value passed inside context is a default value that will only be used if there is no matching Provider in the tree, just found it in the react docs. Could be useful for testing components in isolation.

I guess in the Javascript world one could simply omit passing a value in the context (resulting in undefined), here in Reason however we must pass a value of the correct type (context value). If it is important to not compute the initial value two times (due to some heavy things going on there), one could make it option and pass None in context and Some(computedValue) in the root.