Abstract
Modern UI frameworks encourage unidirectional data flow, explicit state ownership, and composable architectures. However, when state or callbacks must be passed through multiple intermediate layers that do not directly use them, applications suffer from increased coupling, reduced readability, and poor scalability.
In React, this issue is commonly referred to as prop drilling.
In Jetpack Compose, the same structural problem appears as excessive parameter passing or over-hoisted state.
This article presents a framework-agnostic understanding of the problem and demonstrates production-ready solutions in both React and Jetpack Compose, using terminology and patterns that are correct for each platform.
The Core Problem: Deep State Propagation
Framework-Agnostic Definition
Deep state propagation occurs when:
- State or event handlers originate high in the UI tree
- Intermediate components or composables forward them unchanged
- Only deeply nested UI elements actually depend on them
This creates:
- Tight coupling between unrelated layers
- Fragile APIs
- Increased refactoring cost
- Reduced component reusability
How the Problem Appears in React
Example: Excessive Prop Passing
function App() {
const [user, setUser] = useState({ name: "Alex" });
return <Home user={user} />;
}
function Home({ user }) {
return <Dashboard user={user} />;
}
function Dashboard({ user }) {
return <UserMenu user={user} />;
}
function UserMenu({ user }) {
return <div>Welcome {user.name}</div>;
}
Only UserMenu depends on user, yet every component must accept and forward it.
React Solution: Context for Scoped State Access
Context Definition
type UserContextType = {
user: { name: string };
};
const UserContext = createContext<UserContextType | undefined>(undefined);
export function UserProvider({ children }: { children: ReactNode }) {
const [user] = useState({ name: "Alex" });
return (
<UserContext.Provider value={{ user }}>
{children}
</UserContext.Provider>
);
}
export function useUser() {
const context = useContext(UserContext);
if (!context) {
throw new Error("useUser must be used within UserProvider");
}
return context;
}
Consumption
function UserMenu() {
const { user } = useUser();
return <div>Welcome {user.name}</div>;
}
Architectural Outcome
- State ownership is explicit
- Intermediate components remain unaware
- Dependencies are resolved where needed
- The UI hierarchy becomes more resilient to change
How the Same Problem Appears in Jetpack Compose
Jetpack Compose does not use props. Instead, composables receive parameters.
The problem arises when state is hoisted too high and threaded through multiple composables unnecessarily.
Example: Excessive Parameter Passing
@Composable
fun App(userState: UserState) {
HomeScreen(userState)
}
@Composable
fun HomeScreen(userState: UserState) {
Dashboard(userState)
}
@Composable
fun Dashboard(userState: UserState) {
UserMenu(userState)
}
@Composable
fun UserMenu(userState: UserState) {
Text("Welcome ${userState.name}")
}
Only UserMenu requires userState, yet all composables must declare it.
Compose Solution 1: CompositionLocal for Scoped Dependencies
Define a CompositionLocal
val LocalUserState = staticCompositionLocalOf<UserState> {
error("UserState not provided")
}
Provide at an Appropriate Scope
@Composable
fun App() {
val userState = rememberUserState()
CompositionLocalProvider(
LocalUserState provides userState
) {
HomeScreen()
}
}
Consume Where Needed
@Composable
fun UserMenu() {
val userState = LocalUserState.current
Text("Welcome ${userState.name}")
}
Architectural Outcome
- Eliminates parameter threading
- Keeps state scoped to a composition subtree
- Preserves composable reusability
- Aligns with Compose’s composition model
Compose Solution 2: ViewModel as the State Owner (Production Standard)
For non-trivial applications, ViewModels should own UI state.
@HiltViewModel
class UserViewModel @Inject constructor(
repository: UserRepository
) : ViewModel() {
val userState: StateFlow<UserState> = repository.userState
}
@Composable
fun UserMenu(
viewModel: UserViewModel = hiltViewModel()
) {
val user by viewModel.userState.collectAsState()
Text("Welcome ${user.name}")
}
Why This Is Preferred
- State lifecycle is correctly managed
- UI remains stateless
- No deep parameter passing
- Works seamlessly with DI and navigation
Shared Architectural Principles
| Principle | React | Jetpack Compose |
|---|---|---|
| Avoid deep state forwarding | Context | CompositionLocal / ViewModel |
| Explicit state ownership | Provider | ViewModel |
| UI as a pure function | Components | Composables |
| Scoped dependencies | Context boundaries | Composition scope |
When to Use Each Approach
Use Context / CompositionLocal When:
- State is UI-specific
- Scope is limited to part of the tree
- You want explicit scoping without global state
Use ViewModel / External Store When:
- State represents business logic
- Multiple screens consume the same data
- Lifecycle awareness is required
Key Takeaways
- The problem is not props, but deep state propagation
- React and Compose solve the same issue with different primitives
- Excessive parameter passing is a design smell in both ecosystems
- Correct scoping of state improves maintainability and scalability
- Production systems should favor explicit state ownership
Conclusion
Although React and Jetpack Compose use different terminology and APIs, they address the same architectural challenge: preventing UI layers from becoming conduits for state they do not own or use.
By treating this as a state scoping and ownership problem, rather than a framework-specific quirk, teams can design UI systems that are cleaner, more modular, and easier to evolve in production environments.
