Introduction: The Inevitable Complexity of User Flowsβ
Imagine you're tasked with implementing a standard authentication flow in a React application. The requirement seems simple:
- The app must first check the session (look for a stored token).
- If no session is found, redirect to the login page.
- If the session is valid, transition to the authenticated state, granting access to core features.
Most React developers immediately reach for a large, central custom hook / context to manage this logic. They will write something like this:
function useAuth() {
const [status, setStatus] = useState<'checking' | 'login' | 'authenticated'>('checking');
const [user, setUser] = useState(null);
useEffect(() => {
const session = getSession();
if (session.user) {
setStatus('authenticated');
setUser(session.user);
} else {
setsStatus('login');
}
}, []);
const login = async (email: string, password: string) => {
const session = await loginApi(email, password);
if (session.user) {
setStatus('authenticated');
setUser(session.user);
}
};
return { status, login, user };
};
The Inevitable Feature Creepβ
Then, reality hits. A new requirement lands on your desk: the user must not only be authenticated but must also complete their profile with additional data like address, company ID, etc., before accessing the main app.
This means ripping into the heart of your flow logic, modifying all tests for the central hook, and introducing nested conditionals.
You modify your hook, adding the new pending-profile state. After many delicate changes, you arrive at what you believe is a stable solution.
But then, life happens again. A new, critical security requirement arrives: You must integrate a mandatory 2-Factor Authentication (2FA) step between login/signup and the profile completion stage.
This is a true nightmare. It forces you to re-engineer the same block of code again. The logic for transition, validation, and conditional routing is now becoming complex and fragile:
const login = async (email: string, password: string) => {
const session = await loginApi(data);
if (session.user) {
setUser(session.user)
}
};
useEffect(() => {
if (!is2FASet(user)) {
setStatus('2fa-required')
} else if (!isProfileComplete(user)) {
setStatus('pending-profile');
} else {
setStatus('authenticated');
}
}, [user]);
This monolithic approach has failed even in our very simplified example. We are constantly violating the Open/Closed Principle (OCP): modifying existing, working code instead of just extending it.
The Architectural Questionβ
Are we truly following the right architectural path for managing complex, evolving application behavior?
The answer is no. With use-less-react, you can manage this exact problem using a powerful object-oriented design pattern: the State Pattern, implemented as a Finite State Machine (FSM). This approach uses the power of Object-Oriented Programming (OOP) to encapsulate behavior, making your logic robust, extensible, and easy to test.