Introduction

This is part of an ongoing series about the Rescript programming language. I’m taking notes as I build a toy “Recipe Book” app to explore the Rescript ecosystem.

This article adds a couple new React components. They are more complicated than the ones in the previous article because they are managing state and fire actions on form submit. But we’ll find there is still a minimal amount of boilerplate, and Rescript is continuing to bring me scads of delight.

Patreon

This series takes a lot of time to write and maintain. I started it while I have some free time between jobs, but I’ll probably need some extra motivation to keep working on it once I’m busy with work again.

It’s become popular enough that I’ve already fielded a few change requests that have taken additional time. This has slowed me down on new content, but it makes the existing content a lot better.

I’m not one for begging for money on the Internet, but if you’re interested in providing some of that motivation, I’ve set up a Patreon account. I thank you for your support.

As a bonus for patrons, articles will be published there at least two weeks before they make it to my public blog.

Table of Contents

Let’s begin

If you haven’t been following along with these articles, you can git checkout the components branch of the accompanying github repository.

If you want to see the code for this article, I make commits to the stateful-components branch that roughly map to the sections in this article. You may find it instructive to see the diff of the JS that the rescript compiler outputs with each change in your rescript code.

The Add Recipe form

Adding a recipe requires managing state around the keypresses. This can be done easily with the UseState hook, as is standard with React functional component forms. But first, let’s rough out the visual parts of the AddRecipe form. Create a new AddRecipeForm.res file:

@react.component
let make = (~dispatch: Store.action => unit) => {
  <div>
    <div> <input placeholder="Title" /> </div>
    <div> <label> <h3> {React.string("Ingredients")} </h3> <textarea /> </label> </div>
    <div> <label> <h3> {React.string("Instructions")} </h3> <textarea /> </label> </div>
    <button> {React.string("Add!")} </button>
  </div>
}

None of this was unexpected for me, and I hope it doesn’t surprise you, either. Note that we are passing in the dispatch function as a property. It gets passed in from the Apps useReducer through the router switch.

Update the App.res router’s "recipes", "add" route to | list{"recipes", "add"} => <AddRecipeForm dispatch />

There are three text input fields which are currently unmanaged by React. We’ll need to access the state of those fields to submit our action, so let’s turn them into stateful components.

Start with three separate useState hooks at the top of the function:

let (title, setTitle) = React.useState(() => "")
let (ingredients, setIngredients) = React.useState(() => "")
let (instructions, setInstructions) = React.useState(() => "")

The useState hook in normal React can accept either an initial value or a callback that produces an initial value. However, the Rescript binding only permits the callback version. This is why the three useState calls have () => "" passed into them. useState("") would be a compiler error.

The return value of useState is a tuple of the managed state and a function to set new state. We destructure these into two separate variables for each state.

The next step is to arrange for the set* functions to be called in the onchange handlers for the inputs. This took me a bit of ferreting about to figure out. I’ll show the code first and then explain it:

<input
  placeholder="Title"
  value={title}
  onChange={event => {
    let title = ReactEvent.Form.target(event)["value"]
    setTitle(_ => title)
  }}
/>

I’ve expanded the original title input with some new logic. First, the current title state is passed in as the input’s value. Any time title changes, the component will automatically be updated.

Second, the onChange handler is called. It accepts the form’s event and wraps it in ReactEvent.Form.target whose purpose is primarily to tell rescript what type the event has.

The return value of that function is a Rescript object. Looking up an attribute on a Javascript object is done with index notation, hence the ["value"].

We bind to the extracted title using let title =. This step may not seem necessary, but it is; it fails if you do setTitle(ReactEvent.Form.target(event)["value"]).

Finally, we call the setTitle function. Even this looks different from standard react. The parameter must be supplied as a callback instead of passing it in directly. Don’t worry about forgetting that, though. The rescript compiler will remind you. This callback is the reason you had to bind to let title = in the previous line. The variable needs to live long enough for the callback to execute, so it needs to be captured in the closure.

With one component completed, it’s quite straightforward to add state management to the two textareas. The code is a little long (Rescript’s formatter finally started wrapping my components once they got big enough), but has no new concepts:

@react.component
let make = (~dispatch: Store.action => unit) => {
  let (title, setTitle) = React.useState(() => "")
  let (ingredients, setIngredients) = React.useState(() => "")
  let (instructions, setInstructions) = React.useState(() => "")
  <div>
    <div>
      <input
        placeholder="Title"
        value={title}
        onChange={event => {
          let title = ReactEvent.Form.target(event)["value"]
          setTitle(_ => title)
        }}
      />
    </div>
    <div>
      <label>
        <h3> {React.string("Ingredients")} </h3>
        <textarea
          onChange={event => {
            let ingredients = ReactEvent.Form.target(event)["value"]
            setIngredients(_ => ingredients)
          }}
          value={ingredients}
        />
      </label>
    </div>
    <div>
      <label>
        <h3> {React.string("Instructions")} </h3>
        <textarea
          onChange={event => {
            let instructions = ReactEvent.Form.target(event)["value"]
            setInstructions(_ => instructions)
          }}
          value={instructions}
        />
      </label>
    </div>
    <button> {React.string("Add!")} </button>
  </div>
}

Load that in the browser to double check that it is updating fields correctly.

Now we can actually update the state when the user clicks the button. All we need to do is call dispatch. Oh, and add a redirect to the newly added recipe’s page:

<button
  onClick={_ => {
    dispatch(
      Store.AddRecipe({title: title, ingredients: ingredients, instructions: instructions}),
    )
    RescriptReactRouter.push(`/recipes/${title}`)
  }}>
  {React.string("Add!")}
</button>

The dispatch is pretty straightforward. We construct a Store.AddRecipe variant with the stateful values and pass it in. The call to RescriptReactRouter.push is the same RescriptReactRouter.push function we used in the navbars to handle the redirect.

Another form: AddTag

This section will be quick and straightforward. It’s using the exact same logic as AddRecipeForm: manage some state, dispatch an event.

Interesting note: I wrote this entire file without testing anything in the browser. Once Rescript had told me it had compiled successfully, I had enough confidence that it would just work. Considering that rescript compiles faster than React’s hot module reloader can reload, this is an amazingly tight feedback loop.

Here’s the entire AddTag.res file:

@react.component
let make = (~recipeTitle: string, ~dispatch: Store.action => unit) => {
  let (tag, setTag) = React.useState(() => "")
  <div>
    <input
      placeholder="Add tag..."
      value={tag}
      onChange={event => {
        let tag = ReactEvent.Form.target(event)["value"]
        setTag(_ => tag)
      }}
    />
    <button
      onClick={_ => {
        dispatch(Store.AddTag({recipeTitle: recipeTitle, tag: tag}))
      }}>
      {React.string("Add Tag!")}
    </button>
  </div>
}

Unlike AddRecipe, this component isn’t a standalone page. It can be added to the ViewRecipe form under the Tags div:

<div>
  <h3> {React.string("Tags")} </h3>
  <div> {recipe.tags->Array.map(tag => <div> {React.string(tag)} </div>)->React.array} </div>
  <AddTag dispatch recipeTitle={recipe.title} />
</div>

There also needs to be a bit of housekeeping to make dispatch accessible to the component. First, add dispatch: Store.action => unit to the displayRecipe function signature.

Then you’ll need to update the ViewRecipe component constructor’s signature:

let make = (~state: Store.state, ~title: string, ~dispatch: Store.action => unit) => {

Note that this change uses a named property ~dispatch, whereas the previous one was a positional parameter, dispatch. (The difference is in the ~.) Properties in react components must always be named parameters. displayRecipe, though it returns JSX, is not a component proper.

Pass dispatch into the displayRecipe function call in ViewRecipe, and add dispatch to the list of properties ViewRecipe accepts in App.res.

I won’t show the code for this, but you’ll be impressed at how well the linter guides you to find all these changes once you make the first one. If you get lost, check the relevant commit on the github branch. (I learned the hard way not to link directly to github commits. It makes rebasing impossible!)

Conclusion

This was a pretty short article (for me), but it covers a lot of new content. We learned how to dispatch actions in components and to manage state. The trickiest bit (for me) was figuring out how to hook up the form events.

In the next part, we’ll be covering styling.

As a reminder, if you would like to show some support for my work, you can find me on patreon. I publish all my articles there at least two weeks before posting them to my public blog. I’m not looking to make a living from this, but the amount of value my readers place on my free time directly corrolates to how much of my free time I spend on it. At the moment, I’m unemployed and I’m enjoying this project. Once I start work, it’s going to take more incentive to keep me working on this project.

On the other hand, if I get enough support on Patreon, I promise to turn this collection into a well-edited and properly designed book!