The Architecture of Good User Flow

John Cassidy
8 min readMay 2, 2021

As a developer or a designer, you may work on an application that has different functionality depending on what features or capabilities are made available to the user. This article describes how to approach this problem, both from a usability and architectural viewpoint, while maintaining a primary focus on the experience a user has with your application.

What is User Flow?

User flow is how a user interacts with your application to discover and use it’s intended functionality. How a user enters the application and moves between features, makes decisions to create an account, subscribe, or purchase something within the app. Aside from looking good, an application must flow so that the experience itself is pleasant and easy. If a user has to fight your application, or feels it is restrictive, an even easier path is to not use it at all.

Capabilities and Mechanisms of Control

As a developer, you may receive a feature to implement that would be classified as an enhanced capability of the application you are working on. This functionality, usually crucial to the user having the best experience possible, may also be protected through some mechanism of control like a subscription.

As a designer, you may receive a request for one of these enhanced capabilities and need to understand not only how the feature itself works, but also how does it integrate into an application where the user may or may not have a subscription, where the mechanism of control may or may not be satisfied.

Applications can have multiple tiers of capabilities. These capabilities can be determined by some mechanism of control such as an authentication system, subscription model, or Geolocation restrictions.

A video streaming application has the capability to play a movie when conditions of the geolocation service are satisfied. A Realty application has the capability to make immediate offers on properties if the user subscribes to the service monthly. The Greatest App Ever has an amazing collection of capababilities too great to list here, all protected behind a central authentication system.

There are various approaches to dealing with these tiered capabilities, and their subsequent control mechanisms, but they generally fall into a few patterns

  • Allow the user of the application to view part of the guarded capability, but not act on it.
  • Prevent the user of the application from viewing the guarded capability unless the mechanism of control is met.

User flow is crucial to both developer and designer when working with enhanced capabilities and measures of control, and must be considered every step of the way in order to ensure the application is not artificially limited making it more difficult to use. The controls should not be looked at as restrictive, but as another crucial part of the user journey that helps determines the decisions a user makes.

Look But Can’t Touch

A capability that can be viewed but not acted upon will typically inform the user that they are trying to do something that requires additional measures – something like logging in or adding to their subscription.

A typical example would be attempting to watch a live stream of some sporting event or to unlock custom emojis in a chat application. If the measure of control is not met, it may result in an error message indicating that you don’t have the appropriate capability.

Can’t Touch What you Can’t See

A capability that does not reveal itself to exist if the control mechanism is not met, is a more absolute approach. A simple example of this is a gated application, one that requires you to create a user account, or login to an existing account, before displaying any potential functionality. An alternative to a gated application is to allow access to some but not all. The capability is simply removed from the screen where it might exist if a control had been met – If you don’t have a subscription to the service, the call to action to watch the live sporting event never appears.

Application Goals

The above scenarios are typically considered by product managers and designers when building out the user experience of the application. Business objectives, contractual obligations, or attempts at manipulating the user (in the least evil manner possible) are all considered when building each screen with the goal of providing enticing enhancements that provide more functionality, and subsequently generate more revenue.

Architecture Goals

Regardless of the pattern chosen, the architectural approach should be to limit any artificial restrictions in code that may prevent the user from being able to freely flow between capabilities, unlocking mechanisms of control along the way.

If the capability that is not available is unlocked by subscribing to a service within the app, then a mechanism to subscribe should be provided, but it should not unnecessarily hinder the users intent once that subscription is added.

If the user acted on a Call To Action and were then prompted to subscribe, once completed they should immediately proceed as if they had selected the Call To Action while already subscribed to the service. If after completing the subscription flow the application is relaunched in a different state from where they were, they may lose the process of where they were intending to go and in a worst case scenario lose interest and close the application or cancel their subscription with buyers remorse. This is especially important if this interaction is occurring as a first impression with the product.

If it’s too hard to use, and the payoff isn’t worth it – then it won’t be used.

Making Data Accessible

When it comes to mobile application design, specifically in declarative structured applications built in React Native or Flutter, the ability to provide the data required to make appropriate decisions as well as manipulate data to drive the user experience is of incredible importance.

If selecting the Call To Action is to produce a subscription screen of which the user will return to their initial path, then there are a few key pieces needed to maintain the correct context of the user’s flow.

  • Can we easily determine the subscription state from where we are
  • Is there a mechanism to update the subscription state of the user chooses to subscribe
  • Is there a reaction flow to allow the application to handle changes to the subscription state without needing to modify the user flow context. In other words, if the user subscribes can we immediately proceed to the intended action.
  • Once the user is subscribed, the flow should still operate in the same manner except that the need to prompt for subscription is skipped.
Full code for this sample is linked below

Providing Data to Select Components

Flutter and React Native have similar mechanisms to solve a similar problem, one that is faced with declarative languages.

How do we provide stateful information to children without drilling properties or parameters through the layers.

Yes, there are many strategies for State Management in both Flutter and React Native. Yes, there is a healthy debate over what information belongs where. I will indicate my position early here with some example code, but want to make it clear the manner in which you make this data accessible is up to your application design. The takeaway from this article is less about the mechanics of state management, but more the purpose of state management with regards to maintaining user flow.

React Native and Flutter both come with the concept of a Provider. This is a stateful object that is instantiated at a particular level of your application, and is subsequently made available to all children who which to act as a Consumer. In React this is known as the Context API, and in flutter as a Provider. [Opinion incoming] The information stored in these structures should be long living data that is useful for the entire application. If this data changes, then that change is relevant to the entire application. Some examples:

  • User Authentication State and User Data
  • Configuration Data including Feature Flags
  • User Subscription Status

In other words, information where decisions on what is presented to the user can be made by accessing this data in real time.

In this sample application we’ve described, the above image shows which components will consume information in order to have what they need to not interrupt user flow.

React Native Context API

The Context API provides a mechanism for making information available to children of the Context Provider. However you choose to change the data at the top layer, a change will facilitate a re-render to any consumers of the Context.

The full working RN Sample can be found here

For the purpose of this exercise, we will create a model that we make available to the consumers of the Context that allows the data to be changed from the children themselves.

// interface to describe the model that is 
interface SubscriptionModel {
isSubscribed: boolean;
setSubscription: (value: boolean) => void;
}
// Context implementation to be made available
const MySubscriptionContext: Context<SubscriptionModel> = React.createContext<SubscriptionModel>(
{
isSubscribed: false,
setSubscription: () => {},
},
);
// default value with setter capability
const useSubscription = (): SubscriptionModel => {
const [isSubscribed, setIsSubscribed] = React.useState<boolean>(false);
const setSubscription = React.useCallback((value: boolean): void => setIsSubscribed(value),[]);
return {
isSubscribed,
setSubscription,
};
};

with the above in place, we can implement the basic Context Provider and then access it in any children.

const App = () => {
const subscription = useSubscription<SubscriptionModel>();
return (
<MySubscriptionContext.Provider value={subscription}>
<Navigator />
</MySubscriptionContext.Provider>
);
};

Children (consumers of the provider) can read the data, as well as interact with the setter mechanism exposed via the interface.

const subscriptionModel = useContext<SubscriptionModel>(
MySubscriptionContext
);
...
return (
...
<Pressable
onPress={() => {
if (!subscriptionModel.isSubscribed) {
navigation.navigate('subscription', {destination: 'offer'});
} else {
navigation.navigate('offer');
}
}}>
...
<Pressable
onPress={() => {
subscriptionModel.setSubscription(true);
}}>
...
);

The result is the ability to react to data changes at the root of your application, and structure your architecture in a way that allows to user flow to be first and foremost.

Returning to the initial goal, which was to not hinder the users ability to reach their destination, the above usage of the Context API coupled with a Stack Navigator where a Subscription Modal screen exists or does not exist depending on the state of the Context, a User Flow can be created that keeps the user on track with their intent.

Flutter Provider and ChangeNotifier

In a very similar patter, Flutter makes available the concept of a Provider. This provider can be made available to all children widgets via the BuildContext. Methods can be defined, such as setSubsribed, that will allow the child widgets to directly modify the provider (For this simple sample, again a better solution would likely be to offload that responsibility elsewhere).

class SubscriptionProvider extends ChangeNotifier {
bool isSubscribed;

SubscriptionProvider()
: isSubscribed = false,
super();

void setSubscribed(bool value) {
isSubscribed = value;
notifyListeners();
}
}

The owner, responsible for setting up the provider, can make the Provider available to all children

void main() {                         
runApp(ChangeNotifierProvider(
create: (_) => SubscriptionProvider(),
child: MainScreen(),
));
}

Children widgets can consume the provider by wrapping their built widget as a consumer, and then any time the above ChangeNotifier implementation calls to notifyListeners, those widgets will be re-drawn.

@override                         
Widget build(BuildContext context) {
return Consumer<AppProvider>(
builder: (context, subscriptionModel, _) {
return Scaffold(
...
if (!subscriptionModel.isSubscribed)
LockIconWidget()
...
);
}
}

These abilities allow for the flexibility of your application to make decisions on how to keep the user’s intentions intact as they move through your application.

--

--