DEV Community

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

Posted on • Edited on

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

Top comments (0)