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>
}
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>;
}
In some JSON files, one maps the content to put in each placeholder.
{
"greeting": "Hello!"
}
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>;
}
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>;
}
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
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>;
}
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>;
}
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"
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 })
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")) )
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>;
}
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 >>=
under the hood.
Greet () = do
{ t } <- useTranslation ()
return (p t("greeting"))
Breaking down the Haskell code:
useTranslation ()
Gets the result of useTranslation
, but the output is boxed inside m
.
useTranslation () >>= ...
We "pipe" the unboxed record to a function defined at the right of >>=
.
\{ t } -> return (p t("greeting"))
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
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>;
}
We can now say that
-
Greet
is called in the context of a React render -
useTranslation
can be called fromGreet
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 afteruseTranslation
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>;
}
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)
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>
);
}
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>))
);
}
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>))
);
}
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)
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)