DEV Community

loading...
Cover image for ReasonML & ThemeUI in GatsbyJS via Render Props
CangleCode

ReasonML & ThemeUI in GatsbyJS via Render Props

canrau profile image Can Rau ・Updated on ・8 min read

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>
  )
}

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)),
  });
}

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;

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>
);

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

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
}

package.json

{
  "scripts": {
    "re:build": "bsb -make-world -clean-world",
    "re:start": "bsb -make-world -clean-world -w",
    "re:clean": "bsb -clean-world"
  }
}

That's actually it.

Pretty useful links

Official

By Dr. Axel Rauschmayer (@rauschma)

By others

πŸ“Έ Cover image by Victor Garcia on Unsplash

Discussion (0)

Forem Open with the Forem app