Declarative JavaScript
Sultan
Posted on October 23, 2023
Dipping your toes into the world of functional programming can sometimes feel overwhelming. The transition from understanding its core concepts to actually applying them in real-world scenarios can be challenging. If you're familiar with some functional programming techniques but unsure when and how to use them, you're in the right place. In this article, we'll discuss some key concepts and their practical uses, and also explore how declarative programming is connected to functional programming.
Imagine writing JavaScript code without using variables, loops, or logical constructs.
This might sound bold, right? But how often do you find yourself writing code like this to toggle the visibility of a component in JSX?
<div>
{hasComments ? <Comments/> : null}
</div>
We can rewrite it in a more declarative way:
<div>
<Comments visible={hasComments}/>
</div>
As noted, this version is more declarative. Although we still use the conditional statement if
internally, the goal is to abstract it from the main code.
const Comments = ({visible}) => {
if (!visible) return null
...
}
The visible
property prompts us to write code in a declarative manner, even though the component itself is partially implemented in an imperative style.
Let's take a look at the logical operator switch
, which isn't just closely related to if
, but is also a favorite among many developers:
const App = () => {
const role = useUserRole()
let Component
switch(role) {
case 'ADMIN': {
Component = AdminView
break
}
case 'EDITOR': {
Component = EditorView
break
}
case 'USER': {
Component = UserView
break
}
default: {
Component = GuestView
break
}
}
return (
<main>
<NavBar/>
<Component/>
</main>
)
}
Perhaps you’ve already guessed where I'm heading with this, especially since a similar approach is implemented in React Router.
const App = () => (
<main>
<NavBar/>
<Switch test={useUserRole()}>
<Case when='ADMIN' use={AdminView}/>
<Case when='EDITOR' use={EditorView}/>
<Case when='USER' use={UserView}/>
<Otherwise use={GuestView}/>
</Switch>
</main>
)
Let’s consider this code from the perspective of functional programming, where HTML/JSX tags are treated as functions that produce HTML output and tag attributes act as function input parameters.
// it will return HTML: <main id="app">Hello World<main/>
main({id: 'app', children: 'Hello world!'})
// The second parameter can be used as a children attribute
main({id: 'app'}, 'Hello world!')
Drawing from the concept above, let's express the JSX code through function sets. It's pertinent to note that switch/case
are reserved JavaScript keywords, so we'll add underscores:
const app = () => (
main(
navbar(),
switch_({test: useUserRole()},
case_({when: 'ADMIN', use: AdminView}),
case_({when: 'EDITOR', use: EditorView}),
case_({when: 'USER', use: UserView}),
otherwise({use: GuestView}),
)
)
)
As you can see, declarative code doesn't always mean HTML or JSX. In JavaScript, we can use functions to represent the syntax of these languages. According to JS conventions, functions should represent actions and usually start with a verb, such as find
or setTitle
. However, in this case, functions can also represent an entity. For example, an SQL query can be written like this:
// a function composition
query(
select('name', 'email', 'country'),
from('users'),
where({age: less(21)}),
groupBy('country'),
)
// or as a chaining function like Promise
select('name', 'email', 'country')
.from('users')
.where({age: less(21)})
.groupBy('country')
// SELECT name, email, country FROM users WHERE age < 21 GROUP BY country
This way, I want to draw a connection between declarative and functional programming. I believe that functional programming does not intentionally pursue declarativeness; rather, it is a result of extensively using functions. It's worth mentioning that functional programming is not solely about the DRY principle, which involves extracting repetitive code into separate functions. At its core, functional programming focuses on crafting universal functions that are both highly composable and versatile in application.
Now we can take another look at the switch/case
and represent them as a plain function:
const selectComponent= ({test, cases, defaultValue}) => {
const found = cases.find([value] => test === value)
return found?.at(1) || defaultValue
}
const App = () => {
const role = useUserRole()
const Component = selectComponent({
test: role,
cases: [
['ADMIN', AdminView],
['EDITOR', EditorView],
['USER', UserView],
],
defaultValue: GuestView,
})
return (
<main>
<NavBar/>
<Component/>
</main>
)
}
The current implementation falls short in terms of flexibility and composability with other functions. To enhance its utility, we could transform the selectComponent
function into a higher-order function and break it down into more granular functions.
const select = (...fns) => value => fns.reduce(
(found, fn) => found || fn(value), null
)
const when = (test, wanted) => value => {
const matched = typeof test === 'function' ? test(value) : test === value
return matched && wanted
}
const selectComponent = select(
when('ADMIN', AdminView),
when('EDITOR', EditorView),
when('USER', UserView),
() => GuestView,
)
const Component = selectComponent('EDITOR') // -> EditorView
If you're only somewhat familiar with the concept of curried functions, this serves as a solid illustration of their practical use. While a curried function resembles a standard function, its execution can be postponed until all its parameters have been supplied. This capability not only facilitates function composition, but also renders the code more declarative and understandable.
We have the capability to craft various iterations of the when
function, all the while ensuring that the primary select
function remains intact and requires no alterations. Below is an example that demonstrates how to support lazy loading of components:
import {Suspense, lazy} from 'react'
const when = (test, path) => value => (
test === value && lazy(() => import(path))
)
const selectComponent = select(
when('ADMIN', './admin-view'),
when('EDITOR', './editor-view'),
when('USER', './user-view'),
() => GuestView,
)
const App = () => {
const role = useUserRole()
const Component = selectComponent(role)
return (
<main>
<NavBar/>
<Suspense fallback={<div>Loading...</div>}>
<Component/>
</Suspense>
</main>
)
}
If we take a much closer look at the initial version of the selectComponent
function, it becomes clear that it is not possible to extend its functionality without making changes. With this approach, we can break down the code into smaller functions, each with a specific task in mind. This improves the test coverage experience and minimizes the future changes impacting multiple sections of the code. If we ever need to expand the functionality, we can simply add a new function and integrate it with the existing functions rather than rewriting the main function each time.
const between = (min, max) => n => (
min >= n && n <= max
)
// range of values
const toGrade = select(
when(val => val > 90, 'A'),
when(between(80, 89), 'B'),
when(between(70, 79), 'C'),
when(between(50, 69), 'D'),
() => 'F',
)
const grade = toGrade(81) // -> 'B'
In the next post we’ll take a detailed look at this technique using the example of creating Redux reducers. We will learn how to replace the try/catch
approach and continue the discussion on functional programming.
const authReducer = createReducer(
initialState,
on('SIGN_IN', signIn),
on('SIGN_OUT', signOut),
on('SIGN_OUT', clearCookies),
)
Posted on October 23, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
February 20, 2023