Back to Articles

Managing UI Decision Complexity—From Boolean Soup to Business Rules

PK

Paweł Krystkiewicz

October 3, 2024

Also available on Medium

In software development, we often need to check multiple conditions in one step, be it a frontend component or a backend function. In frontend development, it will mostly decide about UI state. Let’s say there are multiple conditions coming from different parts of the system: user permission levels, user subscription plans, entity states, etc.

Usually frontend code grows with the complexity of product’s business logic. This often starts as a simple conditional statement like so:

import React from 'react'

export const App = () => {
// authentication logic here
const authenticated = useIsAuth()
// decide which view to show
return authenticated ? <PrivateRoutes /> : <PublicRoutes />
}

This is very basic conditional logic for displaying private routes only to authenticated (logged in) users.

When things get complicated

The previous example was very rudimentary. Complexity can grow very quickly as business requirements grow and evolve in a Scrum environment. It’s highly likely more conditions will be added to the app’s business logic and be required to check for proper UI display. For this let’s use example of Meta’s Messenger app and the single message entity.

First, we define a simple Message entity with all its relevant properties and context:

type Message = {
id: string
senderId: string
recipientId: string
sentAt: Date
deliveredAt?: Date
seenAt?: Date
deleted: boolean
failed: boolean
attachmentsCount: number
isPinned: boolean
isEdited: boolean
isReply: boolean
isGroupMessage: boolean
currentUserId: string
}

Below is a table of conditions that control how the UI behaves for each message:

<div style=”min-height:980px” id=”datawrapper-vis-r9413"><script type=”text/javascript” defer src=”https://datawrapper.dwcdn.net/r9413/embed.js" charset=”utf-8" data-target=”#datawrapper-vis-r9413"></script><noscript><img src=”https://datawrapper.dwcdn.net/r9413/full.png" alt=”a table of conditions that control how the UI behaves for each message” /></noscript></div>

Visual mapping

  • If isFailedToSend → show red error icon and retry option.
  • If isDelivered but not isSeen → show double-check icon in gray.
  • If isSeen → show double-check icon in blue.
  • If canEdit → show edit button.
  • If showPinnedIcon → display pin in corner.
  • If showReplyPreview → show small reply preview UI.

Now let’s see how this could be implemented without any clever refactors—just growing the codebase overtime.

import React from 'react'
import {
Check,
DoubleCheck,
Pin,
WarningTriangle,
Attachment,
EditPencil,
Reply,
} from 'iconoir-react'
import { Message } from './types'

type MessageProps = {
message: Message
userIsGroupAdmin: boolean
}
export const OldFlagsMessage: React.FC<MessageProps> = ({
message,
userIsGroupAdmin,
}) => {
const isSent = !!message.sentAt && !message.failed
const isDelivered = !!message.deliveredAt && !message.seenAt
const isSeen = !!message.seenAt
const isFailedToSend = message.failed
const canEdit =
message.senderId === message.currentUserId &&
!message.deleted &&
!message.failed
return // render

From terrible mess to terribly clever

The code above is less than ideal, especially around readability. As a first step, one could certainly refactor all those checks into hooks. But let’s go deeper into this refactor, as business requirements will certainly grow, as the only constant in life (and especially in software development) is change. Death and taxes are just a derivative of that.

So we want to create a future-proof refactor that could possibly be a more robust solution that, if necessary, could be used in other places of the system. The latter is an especially good requirement because making this a bit more top-level and detached from the very business logic allows sharing responsibility for this code among many team members. Easing code maintenance and benefiting from sharing ideas around this solution.

There are few things those conditions have in common:

  • result is a boolean flag
  • they can be isolated—they are not interdependent.
  • they depend on a limited source of truth—deciding data passed to the component is a limited set.

All this tells us there is a solution to this problem that could be expressed as

function a => (payload: LimitedDataSet) => boolean

If we could define all boolean flags as such functions, we could include them in a data set that could run each function with the same payload and collect results for each flag. If this is not clever, then I hate React.

So, inspired by eslint rules and cva props evaluation, I created a simple engine that takes an array of rules (not nested) and runs each function with provided props. Each Rule is represented with its name, and the function that returns boolean. The function evaluates the incoming props and returns a boolean value: a Flag.

export type Rules<
A = any,
K extends string | number | symbol = string,
> = Record<K, (props: A)=> boolean>

export type Flags<K extends string | number | symbol= string> = Record<
K,
boolean
>

Modeling these conditions as a rules object

With the above pattern, we can create a set of functions—in this case in an object:

//basically component props in our case
export type MessageRulesArgs = {
message: Message
userIsGroupAdmin: boolean
}

// this is definiton of our required flags
export type MessageRuleSet = {
isSent: boolean
isDelivered: boolean
isSeen: boolean
canEdit: boolean
showPinnedIcon: boolean
showEditedIndicator: boolean
showReplyPreview: boolean
showAttachmentIcon: boolean
showFailedIcon: boolean
showDeleteButton: boolean
}

export type MessageRules = Rules<MessageRulesArgs, keyof MessageRuleSet>

export const messageRules: MessageRules = {
isSent: ({ message: m }) => !!m.sentAt && !m.failed,
isDelivered: ({ message: m }) => !!m.deliveredAt && !m.seenAt,
isSeen: ({ message: m }) => !!m.seenAt,
canEdit: ({ message: m }) =>
m.senderId === m.currentUserId && !m.deleted && !m.failed,
showPinnedIcon: ({ message: m }) => m.isPinned,
showEditedIndicator: ({ message: m }) => m.isEdited,
showReplyPreview: ({ message: m }) => m.isReply,
showAttachmentIcon: ({ message: m }) => m.attachmentsCount > 0,
showFailedIcon: ({ message: m }) => m.failed,
showDeleteButton: ({ message: m }) =>
m.senderId === m.currentUserId && !m.deleted,
}

With rules defined, the remaining thing to do is run and evaluate the result with our data. For this I came up with this little helper:

import type { Flags, Rules } from './types'

export const applyRules = <
A = any,
K extends string | number | symbol = string,
>(
rule: Rules<A, K>,
args: A,
): Flags<K> => {
return Object.keys(rule).reduce((flags: Flags, key: string) => {
const ruleFn = rule[key as K]
if (ruleFn) {
flags[key] = ruleFn(args)
}
return flags
}, {}) as Flags<K>
}

Then, to use it inside a component, we can create a hook:

import { useDeepCompareMemo } from 'use-deep-compare'
import { applyRules } from './apply-rules'
import { Rules } from '../types'

export const useRules = <
RuleSet extends Rules,
Args extends Record<string, any>,
>(
rules: RuleSet,
args: Args,
): Record<string, boolean> => {
// use a deep compare memo to memoize the result regardless of depth
return useDeepCompareMemo(
() => applyRules<Args, keyof RuleSet>(rules, args),
[rules, args],
)
}

Final usage in React component:

import {
Attachment,
Check,
DoubleCheck,
EditPencil,
Pin,
Reply,
WarningTriangle,
} from 'iconoir-react'
import React from 'react'
import { messageRules } from './rules/rules'
import { useRules } from './rules/rules-hook'
import { Message, MessageRules, MessageRulesArgs } from './types'

type MessageProps = {
message: Message
userIsGroupAdmin: boolean
}

export const WithRulesMessage: React.FC<MessageProps> = ({
message,
userIsGroupAdmin,
}) => {
const {
isSent,
isDelivered,
isSeen,
canEdit,
showPinnedIcon,
showEditedIndicator,
showReplyPreview,
showAttachmentIcon,
showFailedIcon,
showDeleteButton,
} = useRules<MessageRules, MessageRulesArgs>(messageRules, {
message,
userIsGroupAdmin,
})

return //render
}

Benefits of this approach

  • Clarity: Each condition is named and easy to understand.
  • Reusability: Components can import and use conditions without duplication.
  • Testability: Each rule can be unit-tested independently.
  • Extensibility: Adding new rules is simple and low-risk.

Conclusion

If you find yourself with growing UI complexity and a web of conditions, consider abstracting those checks into a rules object or lightweight engine. It brings structure, clarity, and scalability to what would otherwise become a nightmare of conditionals and spaghetti code.

Originally published at https://www.pawelkrystkiewicz.pl on October 3, 2024.

Let's build together!

#contact

Like what you see here?
Share your ideas with us. We think big!