Functions can be long and complicated, and the reasons we execute them can change. Separating the reason from the action can make code more reusable and has other benefits.
What's a Predicate?
Predicates are functions that return a Boolean true
or false
. If it helps, you can think of these as the logic in the parentheses of an if (...)
statement. They are the condition, captured as a function.
Mixing Responsibilities
This function is responsible for deciding if we do work and what the work is:
const doImportantTask = (data) => {
if (data
&& data.hasChanged
&& !data.isTooOld
&& !data.isUpdateInProgress) {
const { crucialValue } = data;
// ...many more steps
return processedValue;
}
};
We can easily make the action separate, and reorganize the conditions into early returns – mostly for a style change.
const performImportantCalculation = (value) => {
// ...many more steps;
return processedValue;
};
// Shoot later. Ask questions first.
const doImportantTask = (data) => {
if (!data) return;
if (!data.hasChanged) return;
if (data.isTooOld) return;
if (data.isUpdateInProgress) return;
return performImportantCalculation(data.crucialValue);
};
Even though we separated performImportantCalculation
, we still have two jobs inside doImportantTask
: deciding and running. What if those same conditions apply to some other function we need to run? Let's isolate the predicate so it can be reused.
// Only need one optional chaining with &&
const isDataReady = (data) => data?.hasChanged
&& !data.isTooOld
&& !data.isUpdateInProgress;
const performImportantCalculation = (value) => {
// ...many more steps;
return processedValue;
};
const doImportantTask = (data) => {
if (isDataReady(data)) {
return performImportantCalculation(data.crucialValue);
}
};
This is a contrived example, but we now have a predicate isDataReady
and an action performImportantCalculation
that are not coupled together. This means we can update – and test – the condition separate from the action, and we can easily combine one action with different conditions or one condition with different actions.
Predicates and Hot Loops
Predicates can be even more helpful in code that runs frequently, like middleware. Rather than loading the large function which contains both conditions and actions, the smaller predicate can run to decide if you need to call the larger action.
You can see this pattern inside Redux Toolkit. startListening
takes a type
, actionCreator
, matcher
, or predicate
. These are all conditions to decide if we should bother running the effect
– the action. Doing this saves the program from loading the effect
function. If they were combined, how many times might we load the effect just to perform exit early?
Over-optimized?
As with optimization, you can go overboard. There may not be any performance benefit if your code runs infrequently or the condition is almost always true. It might matter in middleware, view-binding, game engines, or other high-traffic code...places that could process many actions or respond to user interaction.
Performance impact is only one consideration, though.
Maintainability
Small, reusable, and composable functions allow us to avoid writing the same logic multiple times. By breaking it apart we can focus on the writing only the new conditions, new actions, and new ways the pieces fit together.
When you start grouping and naming the conditions separately, the condition names tend to focus on why and the actions focus on what. This can make it easier to follow the logic of your program over time.
// Predicates
const isDataReady = () => { /* ... */ };
const isDeviceOnline = () => { /* ... */ };
const isUserLoggedIn = () => { /* ... */ };
// Actions
const showLoginScreen = () => { /* ... */ };
const fetchUserData = () => { /* ... */ };
const startApp = () => { /* ... */ };
// Now, assemble the pieces.
Functional Style
This also goes well with functional programming style. We've broken the parts down into easily consumed pieces. With a few helpers we can assemble them.
const isGuest = and(isDeviceOnline, not(isUserLoggedIn));
const isUserOnline = and(isDeviceOnline, isUserLoggedIn);
doIf(isGuest, showLoginScreen)(state);
doIf(isUserOnline, fetchUserData)(state);
Conclusion
Small, single-responsibility functions are nothing new, but sometimes a different perspective or even just a reminder on things you already know can be helpful.
Do you create predicates like these in your work? I'd love to hear about how your projects handle repetition and reusability.
Notes
Cover image generated with Microsoft Designer Image Creator.
Top comments (0)