DEV Community

Cover image for Writing better React code with functional programming
Guillaume Desforges
Guillaume Desforges

Posted on

Writing better React code with functional programming

My first few years as a professional programmer, I worked at tweag.io. Though I was not a Haskell consultant myself, the consistent exposure to Haskell got me to dabble with it. There, I learnt about functors, applicatives, monads and such.

Flash forward, no Haskell in sight. My new job involves a good ol' React application. And with a burning international ambition, we needed internationalization.

React, hooks and pure functions

In React, it is common to write functions that return textual content to be rendered. These functions are not function components nor custom hooks. Just a plain function with "pure logic" that computes some value.

function getGreeting(hour: number) {
    if (hour < 12) return "Good morning"
    if (hour < 18) return "Good afternoon"
    return "Good evening"
}

function Greet() {
    const hour = new Date().getHours();
    const greeting = getGreeting(hour);
    return <p>{greeting}</p>
}
Enter fullscreen mode Exit fullscreen mode

getGreeting is entirely defined from its inputs, it is a pure function. Why should we care? Because pure functions are much easier to test.

What's the issue with this code you may ask? Since text is hard-coded in English, we have to internationalize it.

Now, internationalization is not reputed as the most interesting nor rewarding part of software engineering. You create placeholders, you fill some document with all the values, you move on with your life. Let's illustrate it with a simpler example.

function Greet() {
    // using react-i18next, we use this `t` function to put a "translation"
    const { t } = useTranslation();
    return <p>{t("greeting")}</p>;
}
Enter fullscreen mode Exit fullscreen mode

In some JSON files, one maps the content to put in each placeholder.

{
    "greeting": "Hello!"
}
Enter fullscreen mode Exit fullscreen mode

Let's go back to our getGreeting function and translate it.
A naive programmer might want to useTranslation in it.

function getGreeting(hour: number) {
    const { t } = useTranslation();
    if (hour < 12) return t("greeting.morning")
    if (hour < 18) return t("greeting.afternoon")
    return t("greeting.evening")
}

function Greet() {
    const hour = new Date().getHours();
    return <p>{getGreeting(hour)}</p>;
}
Enter fullscreen mode Exit fullscreen mode

In this specific example, it would work. However, let's pretend that there is an early return condition:

function getGreeting(hour: number) {
    const { t } = useTranslation();
    if (hour < 12) return t("greeting.morning")
    if (hour < 18) return t("greeting.afternoon")
    return t("greeting.evening")
}

function Greet({hide}: {hide: boolean}) {
    if (hide) {
        return null;
    }

    const hour = new Date().getHours();
    return <p>{getGreeting(hour)}</p>;
}
Enter fullscreen mode Exit fullscreen mode

You would get the "infamous" warning:

Warning: React has detected a change in the order of Hooks
called by Container. This will lead to bugs and errors if not fixed. For
more information, read the Rules of Hooks:
https://reactjs.org/docs/hooks-rules.html
Enter fullscreen mode Exit fullscreen mode

What happened? By using a React hook in our function, we tainted that function: getGreeting is now a custom hook! Since it is a custom hook, it has to follow the rules of hooks.

The rules of hooks are straightforward.

  • Only call Hooks at the top level
    • Do not call Hooks inside conditions or loops.
    • Do not call Hooks after a conditional return statement.
    • Do not call Hooks in event handlers.
    • Do not call Hooks in class components.
    • Do not call Hooks inside functions passed to useMemo, useReducer, or useEffect.
    • Do not call Hooks inside try/catch/finally blocks.
  • Only call Hooks from React functions
    • Call Hooks from React function components.
    • Call Hooks from custom Hooks.

We can fix our component by putting all hooks before the early return. Also, by convention, a hook should have its name prefixed with use.

function useGreeting(hour: number) {
    const { t } = useTranslation();
    if (hour < 12) return t("greeting.morning")
    if (hour < 18) return t("greeting.afternoon")
    return t("greeting.evening")
}

function Greet({hide}: {hide: boolean}) {
    const hour = new Date().getHours();
    const greeting = useGreeting(hour);

    if (hide) {
        return null;
    }

    return <p>{greeting}</p>;
}
Enter fullscreen mode Exit fullscreen mode

That will be very quickly annoying if, instead of one, we have many of these, which is often the case.

A smart (still naive) programmer may suggest the following.

function getGreeting(t: (key: string) => string, hour: number) {
    if (hour < 12) return t("greeting.morning")
    if (hour < 18) return t("greeting.afternoon")
    return t("greeting.evening")
}

function Greet({hide}: {hide: boolean}) {
    const hour = new Date().getHours();
    const { t } = useTranslation();

    if (hide) {
        return null;
    }

    return <p>{getGreeting(t, hour)}</p>;
}
Enter fullscreen mode Exit fullscreen mode

The idea is smart. Since we have all we need in t, and the "hook" part is getting t, we can turn getGreeting pure again by passing t as an argument. This is a very simple instance of dependency inversion.

Although it works, this design rubs me the wrong way. To understand why, we need to take a step back.

In React, the way we think about component functions and hooks is related to how React works internally for sure, but I would argue that this is also a conscious design made by the React team to provide some sort of "good default" to help developers architecture their app better. I am not sure exactly how they came with this design, but one thing for sure is that, to anyone who has worked with Haskell, it makes a lot of sense. Dare I say, it is the only design that makes sense.

It depend on the context

The following sections will make sense if you are used to working with types, even if you have never seen Haskell code.

To those who know Haskell: yes, I am approximating it, I just need enough to make a point.

Let's consider our initial function getGreeting.

getGreeting :: Int -> String
getGreeting hour =
    if hour < 12 then "Good morning"
    else if hour < 18 then "Good afternoon"
    else "Good evening"
Enter fullscreen mode Exit fullscreen mode

The first line is a type declaration. getGreeting :: Int -> String is a function that takes an integer in and outputs a string. This would be written in TypeScript as getGreeting: (hour: number) => string.

In Haskell, the output of a function is entirely decided by its inputs, we say functions are pure. To have a useTranslation function in Haskell that we can use in a similar way than in TypeScript, we need to output values depending on an implicit context that is not an input, for example the current language. To do that, we need to make useTranslation an impure function (also said to be effectful).

useTranslation :: (ReactRender m) => () -> m ({ t :: String -> String })
Enter fullscreen mode Exit fullscreen mode

This barbaric notation is not an implementation, just a type signature.

What it says is:

  • useTranslation is a function
  • it takes nothing as an input, noted ()
  • is returns a value of type m (think "a generic")
  • this type m "encapsulates" a record type { t :: String -> String }
  • this record type has a field t which is a function that takes a string and returns a string

The notation (ReactRender m) => m says: the concrete implementation of m must implement the interface ReactRender. This allows, when implementing useTranslation, to do the impure stuff, like pulling some state.

Now, this is where Haskell and TypeScript will differ.

In TypeScript code, we happily unwrap the impure context of useTranslation by just running whatever effectful code and returning a record. This is (sort of) what happens under the hood in React's internals.

In Haskell code, we cannot express to "just unwrap" the value inside such context, at least not directly. Instead, we use what is called a continuation thanks to the operator >>=.

Greet () = useTranslation () >>= ( \{ t } -> return (p t("greeting")) )
Enter fullscreen mode Exit fullscreen mode

At this point, you need to stare really hard at the function until you are convinced it is the same as:

function Greet() {
    const { t } = useTranslation();
    return <p>{t("greeting")}</p>;
}
Enter fullscreen mode Exit fullscreen mode

Please, do take a break here to see the similarities.

  • we call useTranslation
  • we get a function t
  • we return a component p
  • its content is the result of t("greeting")

Hint

You may prefer the do-notation that looks more like other languages. It is a convenient syntax that uses &gt;&gt;= under the hood.

Greet () = do
    { t } &lt;- useTranslation ()
    return (p t("greeting"))
Enter fullscreen mode Exit fullscreen mode

Breaking down the Haskell code:

useTranslation ()
Enter fullscreen mode Exit fullscreen mode

Gets the result of useTranslation, but the output is boxed inside m.

useTranslation () >>= ...
Enter fullscreen mode Exit fullscreen mode

We "pipe" the unboxed record to a function defined at the right of >>=.

\{ t } -> return (p t("greeting"))
Enter fullscreen mode Exit fullscreen mode

This is a lambda function declaration, that takes the unwrapped record with a field t, and returns a DOM component <p> with text generated by t("greeting"), wrapped in the current context.

Now, the trick question: what do you think the type of the function Greet is?

In TypeScript it would be Greet: () => React.ReactNode.

In Haskell, it would be... Greet :: (ReactRender m) => () -> m ReactNode.
This type says

  • Greet is a function
  • it takes an empty input
  • it returns a value of type ReactNode boxed in the context of a React render.
  • that "box" is a context that implements the interface ReactNode

Said otherwise, in Haskell we propagate the context, while TypeScript discards it. This context will be unwrapped once we pass that value to an interpreter function.

component = Greet ()

dom = renderWithLocale {locale: "en"} component
Enter fullscreen mode Exit fullscreen mode

Finally, we define a component and interpret it through renderWithLocale, which you'll notice needs the context to be defined, and this gives us some dom of type dom :: ReactNode,
ready to be inserted in the page's DOM!

What we see here is that Haskell enforces the programmer to differentiate functions which are pure from functions which are impure. This leads quite naturally to the "functional core, imperative shell" architecture or similar. As the first activity in software architecture is drawing boundaries, this principle is, in my opinion, one of the first boundaries most software engineers should think about.

It's about sending a message

Having that in mind, let's consider this example again.

function getGreeting(t: (key: string) => string, hour: number): string {
    if (hour < 12) return t("greeting.morning")
    if (hour < 18) return t("greeting.afternoon")
    return t("greeting.evening")
}

function Greet({hide}: {hide: boolean}) {
    const hour = new Date().getHours();
    const { t } = useTranslation();

    if (hide) {
        return null;
    }

    return <p>{getGreeting(t, hour)}</p>;
}
Enter fullscreen mode Exit fullscreen mode

We can now say that

  • Greet is called in the context of a React render
  • useTranslation can be called from Greet because it's called in the context of a React render
  • getGreeting can be seen as a continuation that is applied to the unwrapped context after useTranslation

But! getGreeting does not produce a value that seems like a value in a context. This is not the case in the caller Greet either.

The effect is just run under the hood and we forget about it. Again, this is a key difference between pure programming languages vs the rest of the world.

This leads to a subtle idea: Since our code does not allow to express the effectful nature of its functions, we should limit what is implicit.

Although (t, hour) => string works, it blurs the boundary between pure and impure. Doing it once seems harmless, in larger projects where this is one of many poor design decisions, React code starts being hard to comprehend.

In summary:

  • If you can see just from the code that the getGreeting function is a continuation with a result in a context, I'm wondering why you are reading this blog post.
  • Else, it is probably not a great design decision.

In the end, although it felt more cumbersome at first, in terms of architecture and boundaries, I would recommend explicitly calling the function.

function useGreeting(hour: number) {
    const { t } = useTranslation();
    if (hour < 12) return t("greeting.morning")
    if (hour < 18) return t("greeting.afternoon")
    return t("greeting.evening")
}

function Greet({hide}: {hide: boolean}) {
    const hour = new Date().getHours();
    const greeting = useGreeting(hour);

    if (hide) {
        return null;
    }

    return <p>{greeting}</p>;
}
Enter fullscreen mode Exit fullscreen mode

In a way, this looks simpler, but it is easier to reason about.

  • We call an effectful function (a hook) and get the value,
  • and this value is in an impure context, since we are in a function component (even more so in its hooks part).

This might be easier to see with more explicit typing.

useGreeting :: (ReactRender m) => Int -> m String
useGreeting hour = do
    t <- useTranslation ()
    return (
        if hour < 12 then t("greeting.morning")
        else if hour < 18 then t("greeting.afternoon")
        else t("greeting.evening")
    )

Greet :: (ReactRender m) => Bool -> m ReactNode
Greet hide = do
    hour <- getHours =<< getCurrentTime
    greeting <- useGreeting hour
    return (if hide then React.Fragment else p greeting)
Enter fullscreen mode Exit fullscreen mode

If you squint hard enough, you can see how it maps directly with the TypeScript code, while keeping the impurities explicit.

However, if you have many of these values defined, you'll quickly drown in unnecessary local variables.

function TooManyLocalVariables() {
    const someText = useSomeText();
    const someOtherText = useSomeOtherText();
    const someMoreText = useSomeMoreText();
    // ...

    return (
        <div>
            <p>{someText}</p>
            <p>{someOtherText}</p>
            <p>{someMoreText}</p>
            {/* ... */}
        </div>
    );
}
Enter fullscreen mode Exit fullscreen mode

Even more problematic, you could not really compute the value mapped to an array.

type User = {
    email: string;
    currentTime: Date;
};

function useGreeting(hour: number) {
    const { t } = useTranslation();
    if (hour < 12) return t("greeting.morning")
    if (hour < 18) return t("greeting.afternoon")
    return t("greeting.evening")
}

function GreetUsers({users}: {users: User[]}) {
    return (
        users.map((user) => (<p>{useGreeting(user.currentTime.getHours())}</p>))
    );
}
Enter fullscreen mode Exit fullscreen mode

A change in the length of users would lead to breaking the rules of hooks.

This is where a simple trick will give us the best of both worlds. The intuition of injecting t was not bad, the problem was how the way it was done blurred the line between pure and impure functions.

The solution is to make the boundary explicit.

type User = {
    email: string;
    currentTime: Date;
};

function useGetGreeting() {
    const { t } = useTranslation();
    return (hour: number): string => {
        if (hour < 12) return t("greeting.morning")
        if (hour < 18) return t("greeting.afternoon")
        return t("greeting.evening")
    }
}

function GreetUsers({users}: {users: User[]}) {
    const getGreeting = useGetGreeting();

    return (
        users.map((user) => (<p key={user.email}>{getGreeting(user.currentTime.getHours())}</p>))
    );
}
Enter fullscreen mode Exit fullscreen mode

A custom hook can return a function, and a function defined in another function can use its variables in its body. Using this pattern, we can now think easily: getGreeting is a callback that lives in a context.

If we were to mimick in Haskell, we would still have a straightforward implementation that looks almost identical.

useGreeting :: (ReactRender m) => () -> m (Int -> String)
useGreeting () = do
    t <- useTranslation ()
    return (
        \hour ->
            if hour < 12 then t("greeting.morning")
            else if hour < 18 then t("greeting.afternoon")
            else t("greeting.evening")
    )

Greet :: (ReactRender m) => Bool -> m ReactNode
Greet hide = do
    users <- useUsers ()
    getGreeting <- useGreeting ()
    return (map (\user -> p (getGreeting (user.currentTime & getHours))) users)
Enter fullscreen mode Exit fullscreen mode

Thinking in contexts

Hooks are React's way of making context and impurity explicit.
While there are multiple implementations that could work, by considering hooks as effectful functions and keeping pure logic separate we lean toward a design that makes React code that is easier to reason about.

Although I do not think I will write Haskell professionally (I am not sure I even want to), I must recognize that it has definitely contributed to the way I see code. One of the main things that Haskell teaches is awareness about purity and context. Haskell gave me the vocabulary to see this boundary clearly, and that mindset continues to shape the way I write code.


Special thanks to Clément Hurlin for the review.

Top comments (0)