DEV Community

Cover image for ReasonML & ThemeUI in GatsbyJS via Render Props
Can Rau for CangleCode

Posted on β€’ Edited on

6 1

ReasonML & ThemeUI in GatsbyJS via Render Props

For weeks I was romanticizing ReasonML but didn't find an opportunity so far to actually try it out 😭

Then I started working on the contact form of a new project I'm doing in GatsbyJS, which I started using useState hooks but then decided to use useReducer for the first time, to get a more state machine like experience, when I started to remember Reason's beautiful Pattern Matching and couldn't resist any longer 😁

The Problem

I'm new to ReasonML & Bucklescript and am using Theme UI for styling, which I think is a little more complicated to use in Reason due to the custom JSX Pragma & sx prop magic ✨
Please let me know if you know good ways to integrate/bind.

Render Props to the rescue

So I'm using Render Props to connect logic & styling.
I don't use them often, but they can be pretty useful at times. πŸ‘
For example, I have a layout component which wraps most pages, takes in the original page props and passes certain helpers down/back if the child is a function. This way I can save on state-management/context. 😎

Before

Just for reference that's the pure JS contact form I started out with.

/** @jsx jsx */
import { jsx } from 'theme-ui'
import { useReducer } from 'react'
import isEmail from 'validator/es/lib/isEmail'
import { InputField } from './input-field'

const initialValue = {
  status: 'idle',
  errors: {},
  values: { email: '', message: '', consent: false },
}

function reducer(state, action) {
  switch (action.type) {
    case 'touched':
      return {
        ...state,
        status: 'touched',
        values: { ...state.values, ...action.values },
      }
    case 'submitting':
      return { ...state, status: 'submitting', errors: {} }
    case 'error':
      return {
        ...state,
        status: 'error',
        errors: { ...state.errors, ...action.errors },
      }
    case 'success':
      return { ...initialValue, status: 'success' }
    default:
      throw new Error()
  }
}

export const ContactForm = () => {
  const [{ status, values, errors }, dispatch] = useReducer(
    reducer,
    initialValue
  )
  const collectErrors = {}

  const handleSubmit = event => {
    event.preventDefault()

    dispatch({ type: 'submitting' })

    const cleaned = {
      email: values.email.trim(),
      message: values.message.trim(),
    }

    if (!isEmail(cleaned.email)) {
      collectErrors.email = 'Please provide your best e-mail address'
    }

    if (!cleaned.message) {
      collectErrors.message = 'Please provide a message'
    } else if (cleaned.message.length < 20) {
      collectErrors.message = 'Please be more specific'
    }

    if (!values.consent) {
      collectErrors.consent = 'You have to agree to submit'
    }

    if (Object.keys(collectErrors).length > 0) {
      dispatch({ type: 'error', errors: collectErrors })
      return
    }

    setTimeout(() => {
      dispatch({ type: 'success' })
    }, 2000)
  }

  const setEmail = (_, value) => {
    dispatch({ type: 'touched', values: { email: value } })
  }

  const setMessage = (_, value) => {
    dispatch({ type: 'touched', values: { message: value } })
  }

  const setConsent = (_, value) => {
    dispatch({ type: 'touched', values: { consent: value } })
  }

  const handleKeyDown = event => {
    if (event.metaKey && (event.key === 'Enter' || event.keyCode === 13)) {
      handleSubmit(event)
    }
  }

  return (
    <form
      action=""
      method="post"
      key="ContactForm"
      onSubmit={handleSubmit}
      onKeyDown={handleKeyDown}
    >
      <fieldset disabled={status === 'submitting'} sx={{ border: 0 }}>
        <InputField
          type="email"
          label="E-Mail-Address"
          value={values.email}
          placeholder="mail@example.com"
          onChange={setEmail}
          errorMessage={errors.email}
          required
        />

        <InputField
          type="textarea"
          label="Message"
          value={values.message}
          placeholder="Say hi πŸ‘‹"
          onChange={setMessage}
          errorMessage={errors.message}
          sx={{ marginTop: '1rem' }}
          required
        />

        <InputField
          type="checkbox"
          label="I agree to my e-mail address and message being stored and used to review the request Privacy policy"
          value={values.consent}
          onChange={setConsent}
          errorMessage={errors.consent}
          disabled={status === 'submitting'}
          sx={{ marginTop: '1rem' }}
          required
        />

        <button
          type="submit"
          disabled={status === 'submitting'}
          sx={{ variant: 'buttons.primary', marginTop: '1rem' }}
        >
          Submit
        </button>
      </fieldset>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Initial ContactForm.re

I thought I'd "just" write the following in ReasonML and keep the rest in JS. This way I could progress my learning slowly and mostly leverage the cool pattern matching in my reducer. 😍

type status =
  | Idle
  | Touched
  | Submitting
  | Success
  | Error;

type record = {
  email: string,
  message: string,
  consent: bool,
};

module Errors = {
  type error = {
    mutable email: string,
    mutable message: string,
    mutable consent: string,
  };
};

type state = {
  status,
  errors: Errors.error,
  values: record,
};

let initialValue = {
  status: Idle,
  errors: {
    email: "",
    message: "",
    consent: "",
  },
  values: {
    email: "",
    message: "",
    consent: false,
  },
};

type action =
  | Touched(record)
  | Submitting
  | Success
  | Error(Errors.error);

let reducer = (state, action) => {
  switch (action) {
  | Touched(values) => {...state, status: Touched, values}
  | Submitting => {...state, status: Submitting, errors: initialValue.errors}
  | Error(errors) => {...state, status: Error, errors}
  | Success => {...initialValue, status: Success}
  };
};

[@react.component]
let make = (~children) => {
  let (state, dispatch) = React.useReducer(reducer, initialValue);

  children({
    "status": state.status,
    "values": state.values,
    "errors": state.errors,
    "setTouched": x => dispatch(Touched(x)),
    "setSubmitting": () => dispatch(Submitting),
    "setSuccess": () => dispatch(Success),
    "setError": x => dispatch(Error(x)),
  });
}
Enter fullscreen mode Exit fullscreen mode

After getting this to work and feeling comfortable enough I decided to handle all the logic in ReasonML πŸ™Œ

open ReactEvent.Keyboard;

[@bs.module "validator/es/lib/isEmail"]
external isEmail: string => bool = "default";

[@bs.val] external setTimeout: (unit => unit, int) => unit = "setTimeout";
/* I modified it to return unit instead of float
   because of some error I got but don't remember right now
   and is only used to fake an async submit until I implement the actual logic */

type status =
  | Idle
  | Touched
  | Submitting
  | Success
  | Error;

type record = {
  email: string,
  message: string,
  consent: bool,
};

module Errors = {
  type error = {
    mutable email: string,
    mutable message: string,
    mutable consent: string,
  };
};

type state = {
  status,
  errors: Errors.error,
  values: record,
};

let initialValue = {
  status: Idle,
  errors: {
    email: "",
    message: "",
    consent: "",
  },
  values: {
    email: "",
    message: "",
    consent: false,
  },
};

type action =
  | Touched(record)
  | Submitting
  | Success
  | Error(Errors.error);

let reducer = (state, action) => {
  switch (action) {
  | Touched(values) => {...state, status: Touched, values}
  | Submitting => {...state, status: Submitting, errors: initialValue.errors}
  | Error(errors) => {...state, status: Error, errors}
  | Success => {...initialValue, status: Success}
  };
};

[@react.component]
let make = (~children) => {
  let (state, dispatch) = React.useReducer(reducer, initialValue);

  let handleSubmit = event => {
    ReactEvent.Synthetic.preventDefault(event);
    let collectErrors: Errors.error = {email: "", message: "", consent: ""};

    dispatch(Submitting);

    let email = Js.String.trim(state.values.email);
    let message = Js.String.trim(state.values.message);

    if (!isEmail(email)) {
      collectErrors.email = "Please provide your best e-mail address";
    };

    /*
    let msgLength = String.length(message);
    if (msgLength === 0) {
      collectErrors.message = "Please provide a message";
    } else if (msgLength < 20) {
      collectErrors.message = "Please be more specific";
    };
    */

    switch (String.length(message)) {
    | 0 => collectErrors.message = "Please provide a message"
    | (x) when x < 20 => collectErrors.message = "Please be more specific"
    | x => ignore(x)
    };

    if (!state.values.consent) {
      collectErrors.consent = "You have to agree to submit";
    };

    /*
    Not my best work πŸ˜‚
    showing alternative syntax |> & ->
    I'm using the latter in my "real" code
    it's in this case a little more concise as it formats nicer
    a little bit confusing maybe πŸ€”, also I don't like this formatting actually πŸ€·β€β™‚οΈ
    */
    if (String.length(collectErrors.email) > 0
        || collectErrors.message
        |> String.length > 0
        || collectErrors.consent->String.length > 0) {
      dispatch(Error(collectErrors));
    } else {
      /* Submit logic has yet to come as I'm focusing on UI first */
      setTimeout(() => dispatch(Success), 2000);
    };
  };

  let handleKeyDown = event =>
    if (event->metaKey && (event->key === "Enter" || event->keyCode === 13)) {
      handleSubmit(event);
    };

  let status =
    switch (state.status) {
    | Idle => "idle"
    | Touched => "touched"
    | Submitting => "submitting"
    | Success => "success"
    | Error => "error"
    };

  let props = {
    "status": status,
    "values": state.values,
    "errors": state.errors,
    "setTouched": x => dispatch(Touched(x)),
  };

  <form
    action=""
    method="post"
    key="ContactForm"
    onSubmit=handleSubmit
    onKeyDown=handleKeyDown>
    {children(props)}
  </form>;
};

let default = make;
Enter fullscreen mode Exit fullscreen mode

Most stuff looks more or less okay I guess. Only thing I'm reeaally not sure but didn't manage to find another solution right away is all the collectErrors stuff.
There's maybe, better ways I just don't know yet πŸ™πŸ€·β€β™‚οΈ Once I do, maybe because of nice feedback (via Twitter) I'll come back to improve it.

Uh and I tried to pass more specific helper functions like setMail down to children but couldn't get the reducer for them working so far.

JS file just for styling purpose

/** @jsx jsx */
import { jsx } from "theme-ui";
import { InputField } from "components/input-field.js";
import { make as ContactFormLogic } from "components/ContactForm.bs.js";

export const ContactForm = () => (
  <ContactFormLogic>
    {({ status, values, errors, setTouched }) => (
      <fieldset disabled={status === "submitting"} sx={{ border: 0 }}>
        <InputField
          type="email"
          label="E-Mail-Address"
          value={values.email}
          placeholder="mail@example.com"
          onChange={(_, value) => setTouched({ ...values, email: value })}
          errorMessage={errors.email}
          required
        />

        <InputField
          type="textarea"
          label="Message"
          value={values.message}
          placeholder="Say hi πŸ‘‹"
          onChange={(_, value) => setTouched({ ...values, message: value })}
          errorMessage={errors.message}
          sx={{ marginTop: "1rem" }}
          required
        />

        <InputField
          type="checkbox"
          label="I agree to my e-mail address and message being stored and used to review the request Privacy policy"
          value={values.consent}
          onChange={(_, value) => setTouched({ ...values, consent: value })}
          errorMessage={errors.consent}
          disabled={status === "submitting"}
          sx={{ marginTop: "1rem" }}
          required
        />

        <button
          type="submit"
          disabled={status === "submitting"}
          sx={{ variant: "buttons.primary", marginTop: "1rem" }}
        >
          Submit
        </button>
      </fieldset>
    )}
  </ContactFormLogic>
);
Enter fullscreen mode Exit fullscreen mode

Thoughts on ReasonML

I really enjoy using it, not being able to spread props or multiple times into objects/records is still a little confusing. But that's a trade-off I'm willing to accept.

Actually I'm looking forward to a stable release of elodin by @robinweser probably in conjunction with fela to replace ThemeUI and drop the additional JS file. We'll see..

How I do ReasonML in GatsbyJS

I started with the help of gatsby-plugin-reason only to discover it's pretty outdated and bs-loader is not even recommended anymore.
Took me a while to figure this out though while trying to understand why nothing was working^^

Installing ReasonML & ReasonReact in an existing GatsbyJS project

yarn install reason-react && yarn install -D bs-plattform
Enter fullscreen mode Exit fullscreen mode

By the way I'm on reason-react@0.7.0 & bs-platform@7.2.2

bsconfig.json

{
  "name": "PROJECT_NAME",
  "reason": { "react-jsx": 3 },
  "bsc-flags": ["-bs-super-errors"],
  "sources": [
    {
      "dir": "src",
      "subdirs": true
    }
  ],
  "package-specs": [
    {
      "module": "es6",
      "in-source": true
    }
  ],
  "suffix": ".bs.js",
  "namespace": true,
  "bs-dependencies": ["reason-react"],
  "ppx-flags": [],
  "refmt": 3
}
Enter fullscreen mode Exit fullscreen mode

package.json

{
  "scripts": {
    "re:build": "bsb -make-world -clean-world",
    "re:start": "bsb -make-world -clean-world -w",
    "re:clean": "bsb -clean-world"
  }
}
Enter fullscreen mode Exit fullscreen mode

That's actually it.

Pretty useful links

Official

By Dr. Axel Rauschmayer (@rauschma)

By others

πŸ“Έ Cover image by Victor Garcia on Unsplash

Sentry blog image

How I fixed 20 seconds of lag for every user in just 20 minutes.

Our AI agent was running 10-20 seconds slower than it should, impacting both our own developers and our early adopters. See how I used Sentry Profiling to fix it in record time.

Read more

Top comments (0)

Heroku

This site is built on Heroku

Join the ranks of developers at Salesforce, Airbase, DEV, and more who deploy their mission critical applications on Heroku. Sign up today and launch your first app!

Get Started

πŸ‘‹ Kindness is contagious

Please leave a ❀️ or a friendly comment on this post if you found it helpful!

Okay