When imperative is better than declarative in React
When completely sticking to a declarative approach that separates updates to data and display of UI, there are certain situation where the flow of you component logic is no longer linear and thus becomes hard to read and understand. In this article we will try to wrap a declarative modal dialog API in an imperative API that gives developers flexibility while keeping the control flow linear and easy to follow.
The rise of React
One of the largest selling points of React in comparison to jQuery and friends was its declarative approach. You, as a developer, could stop thinking about how you get your UI from one state to another. You would simply update the underlying state and the UI would magically follow:
function CounterButton() {
const [currentCount, setCurrentCount] = useState(0);
const nextCount = currentCount + 1;
return (
<div>
<p>Current count: {currentCount}.</p>
<button onClick={() => setCurrentCount(currentCount + 1)}>
After clicking, the value will be {nextCount}.
</button>
</div>
);
}
In this example, we don't have to worry about in how many places the current count is used in the UI or to calculate derived values (like nextCount
). All our component does, is describe (or declare?) how the UI should look like when given a specific state. When we change the underlying data, React handles the transition from one UI to the next. If you are interested in how React accomplishes this, you can check out the official docs about the reconciliation process.
Most of the time, this declarative approach makes development simpler for us. But there are several cases, where it may become tedious.
When declarative becomes a burden
Let's start with a base component, that display a list of users:
type User = {
id: number;
name: string;
};
function App() {
const [users, setUsers] = useState<User[]>([
{ id: 1, name: "peter" },
{ id: 2, name: "tony" },
]);
return (
<div>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name} <button>delete</button>
</li>
))}
</ul>
</div>
);
}
Now we want to delete users by clicking the delete button. However, deleting things is often times an irreversible process. So, to be sure, when we are clicking the button, we want to show a confirmation dialog, where we have to explicitly confirm that we want to delete. For this, we built a small dialog component:
import Dialog from "@reach/dialog";
import { ReactNode } from "react";
function ConfirmationDialog(props: {
ariaLabel: string;
children: ReactNode;
onCancel: () => void;
onConfirm: () => void;
}) {
return (
<Dialog onDismiss={props.onCancel} aria-label={props.ariaLabel}>
<p>{props.children}</p>
<button onClick={props.onCancel}>Close</button>
<button onClick={props.onConfirm}>Confirm</button>
</Dialog>
);
}
Here we make use of the excellent dialog component from ReachUI to make sure our dialog is accessible to all users. This component is still completely declarative: When this is rendered, the dialog is visible. No imperative open
or close
calls.
Let's add some new state and functions to our app to prepare the use of our confirmation dialog:
function App() {
// ...
// We need a new state to track which user should be deleted
const [userToBeDeleted, setUserToBeDeleted] = useState<User | null>(null);
// When we click delete, we store the user to be deleted
function handleDeleteClick(user: User) {
setUserToBeDeleted(user);
}
// Only when we confirm in our dialog, do we really delete the user.
function deleteUser(user: User) {
// Make some API Request to delete
console.log(user);
// Reset the userToBeDeleted state to hide the modal
setUserToBeDeleted(null);
}
// ...
}
We can already see, that our code becomes sort of disconnected. Clicking the delete button only sets the state and nothing else. To track down what exactly is happening after the delete click we have to look at how this state is used:
default function App() {
// ...
const [userToBeDeleted, setUserToBeDeleted] = useState<User | null>(null);
function handleDeleteClick(user: User) {
setUserToBeDeleted(user);
}
function deleteUser(user: User) {
console.log(user);
setUserToBeDeleted(null);
}
return (
<div>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name}{" "}
<button onClick={() => handleDeleteClick(user)}>delete</button>
</li>
))}
</ul>
{userToBeDeleted && (
<ConfirmationDialog
ariaLabel="Confirm user deletion."
onCancel={() => setUserToBeDeleted(null)}
onConfirm={() => deleteUser(userToBeDeleted)}
>
Should the user "{userToBeDeleted.name}" really be deleted?
</ConfirmationDialog>
)}
</div>
);
}
When we try to trace what is happening when we click the delete button, we first have to jump up into the handleDeleteClick
function. This sets the clicked user in a state variable. Now we have to scroll back down and track, where we are using this state variable and find, that we render the dialog when a user is set in the state variable. When we click the confirm button, we have to scroll up yet again to the deleteUser
function to see that we could be calling some api function to really delete the user.
I hope you can see that this is not optimal. By sticking to the declarative approach, we no longer have one understandable flow when trying to follow the process of what is happening after a delete click.
Imagining a nicer API
Let's try to come up with a better approach. The overall flow that we want to build is the following:
- User clicks on delete
- Ask the user to confirm their action
- If not confirmed, abort
- If confirmed, delete the user
Here is how we could model that flow with the native confirm
function of the browser:
default function App() {
// ...
function handleDeleteClick(user: User) {
const shouldBeDeleted = confirm(
`Should the user "${user.name}" really be deleted?`
);
if (!shouldBeDeleted) return;
// Make API call here
console.log(user);
}
// ...
}
This is much more linear. Just by looking at this function, we can see what the purpose of this is. There is one problem however: By using the native confirm
, we lose the ability to apply custom styling to this dialog. Additionally, note that confirm
is synchronous. As long as the dialog is open, the browser window freezes. No updates, no animations, no timers.
Let's imagine an api that allows us to completely customize the contents of the dialog while still letting the event loop run:
default function App() {
// ...
async function handleDeleteClick(user: User) {
const shouldBeDeleted = await openModal<boolean>((close) => (
<ConfirmationDialog
ariaLabel="Confirm user deletion."
onCancel={() => close(false)}
onConfirm={() => close(true)}
>
Should the user "{user.name}" really be deleted?
</ConfirmationDialog>
));
if (!shouldBeDeleted) return;
// Make API call here
console.log(user);
}
// ...
}
Of course, this increases the size of our event handler a bit. In my oppinion, this is still legible while keeping the linear flow of the delete action. On top of that, we gain complete customizability of the dialog.
Now we need to somehow implement this openDialog
function. We already know the interface and can fill it with some life:
/**
* A function that takes a result of a variable type and returns nothing.
* This will close our modal and return to the caller of `openModal`.
*/
type CloseModal<ResultType> = (result: ResultType) => void;
/**
* A function that builds the UI for a modal dialog.
* It takes the close function as a parameter and returns a `ReactNode`
* that we can display.
*/
type ModalFactory<ResultType> = (close: CloseModal<ResultType>) => ReactNode;
/**
* openModal receives a ModalFactory as an argument and returns a promise
* that will resolve with the result.
*/
function openModal<ResultType>(
modalFactory: ModalFactory<ResultType>
): Promise<ResultType> {
// Create a new promise that we can control
return new Promise<ResultType>((resolve) => {
function closeModal(result: ResultType) {
// By calling resolve, we return the result to the caller
// by resolving the promise.
resolve(result);
}
// Create the JSX for our dialog
const modal = modalFactory(closeModal);
// ...
});
}
Now we have a function that creates some JSX. In order to display that, we have to save this JSX somewhere and return it from a React component. Sounds like a custom hook to me:
function useModal() {
// The react node has to be stored somewhere
const [modalNode, setModalNode] = useState<ReactNode>(null);
function openModal<ModalResult>(modalFactory: ModalFactory<ModalResult>) {
return new Promise<ModalResult>((resolve) => {
function close(value: ModalResult) {
resolve(value);
// When the modal should be closed, we set our state to null
// to stop rendering a dialog
setModalNode(null);
}
// To open the dialog, we store the resulting jsx in our state
setModalNode(modalFactory(close));
});
}
// We return the modalNode (or null) and the openModal function
return [modalNode, openModal] as const;
}
Here, we wrapped the openModal
function in a custom hook. Now we can store the created modal in a state and display that state until someone calls the close function. On close, we resolve the promise and remove the modal from the state to hide it again.
Let's put everything together to see our useModal
hook in action:
function App() {
const [users, setUsers] = useState<User[]>([
{ id: 1, name: "peter" },
{ id: 2, name: "tony" },
]);
const [modalNode, openModal] = useModal();
async function handleDeleteClick(user: User) {
const shouldBeDeleted = await openModal<boolean>((close) => (
<ConfirmationDialog
ariaLabel="Confirm user deletion."
onCancel={() => close(false)}
onConfirm={() => close(true)}
>
Should the user "{user.name}" really be deleted?
</ConfirmationDialog>
));
if (!shouldBeDeleted) return;
// Make some API Request to delete
console.log(user);
}
return (
<div>
<ul>
{users.map((user) => (
<li key={user.id}>
{user.name}{" "}
<button onClick={() => handleDeleteClick(user)}>delete</button>
</li>
))}
</ul>
{modalNode}
</div>
);
}
Note that we return the currently displayed modal from our useModal
hook. This will be filled with a ReactNode when a modal is open, and with null when it's closed. We have to adapt the JSX returned from our component to include the modal node, so that it get's rendered, when we need an open modal.
By using this new custom hook, we introduced an imperative api to retrieve some information from the user. Note that under the hood, we are still using React's declarative rendering approach: Update some data (the jsx of a modal) and React handles the UI. We only hid these declarative details in an imperative API to make consumption easier.
In our case, we needed a simple confirmation (true
or false
). However, we could also request more complex information like letting the user select from different login providers (e.g. twitter
, github
, facebook
) or showing the user a table of user entities and letting them select more than one.
In the end, we have to aknowledge that some processes are intrinsically imperative: "User, select an Option!", "User, are you sure you want to continue?". By enabling developers to imperatively embed these prompts in functions, we allow a more linear control flow and make components easier to write and understand.
If you want to play around with our newly created hook, head over to this codesandbox and give it a try.
All this is only possible because React treats UI as data that can be stored in variables or returned from functions — but more on this in one of the following articles.