Nathan Fallet
Nathan Fallet

Reputation: 465

Using ViewModels in React Native (instead of Redux)

State management in React Native is a well-debated topic, with Redux often being the default choice. However, for developers coming from native mobile development (Kotlin/Swift), Redux can feel cumbersome with its actions, reducers, and dispatching.

As someone experienced in native development, I built my own ViewModel-based state management system in React Native to align better with the MVVM (Model-View-ViewModel) architecture commonly used in Android and iOS. This approach keeps things modular, structured, and easy to scale.

I'll walk you through why I built this system, how it works, and how it compares to Redux.

Why Not Redux?

While Redux is powerful, it comes with some drawbacks:

For these reasons, I built a ViewModel-based approach that solves these problems while keeping things modular and scalable.

The Core Concept: ViewModels in React Native

In native development, ViewModels handle UI logic and expose state for the View (UI) to consume. I recreated this concept in React Native using React Context and hooks, making it easy to encapsulate both global and screen-specific state.

Here are 3 examples about how I use this ViewModel system:

  1. RootViewModel – Handles app-wide state (e.g., user authentication, theme settings).
  2. TodoListViewModel – A standard ViewModel that lists todo items.
  3. TodoItemViewModel – A ViewModel with data, that takes a todo item ID from the route to manage state for a single item.

This shows how you can have a shared ViewModel, or ViewModels scoped to screens, using data from route or not.

Base definitions

I use the following types to simplify ViewModel creation and usage:

export type ViewModel = React.FC<{ children?: React.ReactNode }>
export type ParamsViewModel<T> = React.FC<{ children?: React.ReactNode } & T>

export const withViewModel: <T extends object>(
    Component: React.FC<T>,
    ViewModel: ViewModel,
) => React.FC<T> = (Component, ViewModel) => (props) => {
    return <ViewModel>
        <Component {...props}/>
    </ViewModel>
}

export const withParamsViewModel: <T extends object>(
    Component: React.FC<T>,
    ViewModel: ParamsViewModel<T>,
) => React.FC<T> = (Component, ViewModel) => (props) => {
    return <ViewModel {...props}>
        <Component {...props}/>
    </ViewModel>
}

Then I can wrap my components with ViewModel like this:

const MyComponent: React.FC = () => {
    const { data } = useSomeViewModel()
    return <></>
}

export default withViewModel(MyComponent, SomeViewModel)

RootViewModel: Managing Global State

My RootViewModel acts as a global store but without the complexity of Redux. It provides essential global data (e.g., user info) and ensures that any ViewModel can access it.

Example:

const RootViewModelContext = createContext({
    user: null as User | null,
    login: async (credentials: Credentials) => {},
})

export const RootViewModel: ViewModel = ({ children }) => {
    const [user, setUser] = useState<User | null>(null)
    const { authRepository } = useDI()
    
    const login = async (credentials: Credentials) => {
        const newUser = await authRepository.login(credentials)
        if (newUser) setUser(newUser)
    }

    return <RootViewModelContext.Provider value={{ user, login }}>
        {children}
    </RootViewModelContext.Provider>
}

export const useRootViewModel = () => useContext(RootViewModelContext)

Now, any component or ViewModel can access global state via useRootViewModel() while keeping setUser private.

TodoListViewModel: Managing a List of Todo Items

This ViewModel is not parameterized and is used to manage a list of todos.

Example:

const TodoListViewModelContext = createContext({
    todos: [] as Todo[],
    fetchTodos: async () => {},
})

export const TodoListViewModel: ViewModel = ({ children }) => {
    const [todos, setTodos] = useState<Todo[]>([])
    const { todoRepository } = useDI()
    
    const fetchTodos = async () => {
        const data = await todoRepository.getTodos()
        setTodos(data)
    }

    useEffect(() => {
        fetchTodos()
    }, [])

    return <TodoListViewModelContext.Provider value={{ todos, fetchTodos }}>
        {children}
    </TodoListViewModelContext.Provider>
}

export const useTodoListViewModel = () => useContext(TodoListViewModelContext)

TodoItemViewModel: Managing a Single Todo Item

This ViewModel takes a parameter (todo item ID) from navigation and manages the state of a single todo item.

Example:

export const TodoItemViewModel: ParamsViewModel<NativeStackScreenProps<TodoNavigationRoutes, "TodoItemScreen">> = ({
    children, navigation, route
}) => {
    const [todo, setTodo] = useState<Todo | null>(null)
    const { todoRepository } = useDI()

    useEffect(() => {
        todoRepository.getTodo(route.params.todoId).then(setTodo)
    }, [route.params.todoId])

    return <TodoItemViewModelContext.Provider value={{ todo }}>
        {children}
    </TodoItemViewModelContext.Provider>
}

Why This Works Better Than Redux for Screens

Final Comparison: My ViewModel System vs. Redux

Feature ViewModel System Redux
State Location Global (RootViewModel) + per-screen (ParamsViewModel) Global Redux store
Performance Optimized with Context splitting, useMemo, and useCallback Optimized with useSelector
Boilerplate Minimal, simple DI-friendly architecture Requires reducers, actions, and dispatch logic
Navigation Support Uses route.params naturally Requires extra logic to sync navigation state
Best for Modular, self-contained apps Large-scale apps with cross-screen state

Conclusion

If you're coming from native Android/iOS development, this ViewModel approach offers a structured, modular way to manage state in React Native while avoiding Redux’s complexity.

By leveraging RootViewModel for global state, TodoListViewModel for lists, and TodoItemViewModel for single items, you get:

What do you think about this approach?

Upvotes: 2

Views: 31

Answers (0)

Related Questions