Design React Native app with MVP (Model-View-Presenter) architecture

Kai Xie
14 min readDec 26, 2022

--

React Native is a JavaScript framework for building native mobile applications. It allows developers to build mobile apps for iOS and Android using a single codebase and the popular React JavaScript library. React Native uses a declarative programming style, which makes it easy to create interactive and intuitive user interfaces. It also includes a rich set of components and APIs that allow developers to access native features of the device, such as the camera, GPS, and more. Overall, React Native is a powerful and flexible platform for building mobile applications.

But the restriction of React Native is it primarily focused on building the user interface (UI) of a mobile app, and it does not provide any specific guidance on how to design the data layer of the app.

However, there are several third-party libraries and tools, such as Redux and MobX, that can be used to manage the data and state of a React Native app. These libraries provide a way for the UI components to access and update the data in a structured and predictable way.

That means ww should consider decoupling the data and the UI, with the data being managed by a separate layer of the app and the UI being responsible for displaying and interacting with the data. This separation of concerns can help to make the app more maintainable and easier to understand. This is what design pattern or software architecture does.

We know there are many different software architectures and design patterns that can be used to organize and structure the code of a software application. Some of the most common software architectures and design patterns include:

  • MVC (Model-View-Controller): This architecture separates the application into three components: the Model, which represents the data and business logic of the app; the View, which represents the user interface; and the Controller, which acts as a mediator between the Model and the View.
Model-View-Controller
  • MVP (Model-View-Presenter): This architecture is similar to MVC, but the Presenter component acts as a mediator between the Model and the View instead of the Controller.
Model-View-Presenter
  • MVVM (Model-View-ViewModel): This architecture separates the application into three components: the Model, which represents the data and business logic of the app; the View, which represents the user interface; and the ViewModel, which acts as a mediator between the Model and the View and handles data transformation and presentation logic.

Each of these software architectures and design patterns has its own strengths and weaknesses, and the best one to use will depend on the specific needs of the application.This article would provide an example about how to design a React Native app with MVP (Model-View-Presenter) architecture.

This article will raise and example to design a React Native app with MVP architecture for the separation of data and UI.

Create a new project

Let’s create a React Native project with TypeScript template by running following command.

npx react-native init mvp --template react-native-template-typescript

and update the Package.json, tsconfig.json and .eslinttrc.js as you preferred, All these configuration files are about the syntax check and lint settings.

and run

yarn install
cd ios && pod repo update && pod install && cd ..

to install all dependencies.

If all dependencies, including node_modules and pods, are installed, you can run

yarn ios

to launch the app on an iOS simulator.

And you would see the default app UI like

And then you can open the project with some IDE like intelliJ IDEA and open the App.tsx, which is the main UI component of this app.

We can remove unnecessary code like Section and LearnMoreLink parts.

Define the Data Model

The Model represents the data and business logic of the app. It is responsible for managing the app’s state and performing any necessary calculations or data manipulation.

We can create a new file, data.ts, and define a new interface as

export interface Data1 {
type: string
props: Record<string, any>
}

Then we defined a data model with a data type, Data1.

Define the Data Source

In a software application, the data source module is responsible for managing the data that is used by the app. This typically includes tasks such as retrieving data from a database or other external source, storing data locally, and providing data to other parts of the app as needed.

The data source module is often designed as a separate layer of the app, with a well-defined interface that allows other parts of the app to access the data. This helps to keep the data management logic separate from the rest of the app and makes it easier to test and maintain.

In this example app, we can just put the following hardcoded data in DataSource.ts

import { Data1 } from './data'

export const data1: Data1[] = [
{
type: '1-1',
props: { subType: '1-1', prop1: '1-1-1', prop2: '1-1-2' }
},
{
type: '1-2',
props: { subType: '1-2', prop1: '1-2-1', prop2: '1-2-2' }
},
{
type: '1-3',
props: { subType: '1-3', prop1: '1-3-1', prop2: '1-3-2' }
}
]

Define the View

The View represents the user interface of the app. It is responsible for rendering the app’s components and handling user input.

In this example, we want to display the type and first element in the propsof the data, so we can update the App.tsx as

<ScrollView
contentInsetAdjustmentBehavior="automatic"
style={backgroundStyle}>
<Header />
<View
style={{
backgroundColor: isDarkMode ? Colors.black : Colors.white
}}>
{
data1.map((el: Data1, index:number) =>
<Section key={index} title={el.type}>
{el.props.prop1}
</Section>
)
}
</View>
</ScrollView>

Then if we run the app, we would see the like

This is the simplest way to access the data in the data source from view. Ok, it works. But let’s think about what would happen if we change the type of the data in data source or introduce another type of data? We need to update the view accordingly.

For instance, we update the data1 like

export const data1: Data1[] = [
{
type: '1-1',
props: { subType: '1-1', prop1: '1-1-1', prop2: '1-1-2' }
},
{
type: '1-2',
props: { subType: '1-2', prop1: '1-2-1', prop2: '1-2-2' }
},
{
type: '1-3',
props: { subType: '1-3', prop1: '1-3-1', prop2: '1-3-2' }
}
]

and we define a new type of data like

export interface Data2 {
name: string
props: Record<string, any>
}

and add some example data of Data2 to the data source like

export const data2: Data2[] = [
{
name: '2-1',
props: { nickname: '2-1', prop1: '2-1-1', prop2: '2-1-2' }
},
{
name: '2-2',
props: { nickname: '2-2', prop1: '2-2-1', prop2: '2-2-2' }
}
]

and we want to display the data2 in the view as well and want to display the filed subType for data1 and nickname for data2 as the description on the view. We notice the type of data2 is different and doesn’t have type field, so we need to handle the it before we render it.

Is there any better approach? Ok, let’s add an intermediate layer between data source and the view and refactor the code as MVP architecture.

Define the Presenter

The Presenter acts as a mediator between the Model and the View. It is responsible for updating the Model based on user input and updating the View based on changes to the Model.

Firstly, we create a file, Presenter.ts, and define an interface Presenter like

export interface Presenter<T> {
getData(): T
}

We only need one API, getDatain this interface in current stage.

And we can define a class to implement this interface like

export class PresenterImp implements Presenter<ViewData[]> {
getData(): ViewData[] {
return [...DataSource.data1, ...DataSource.data2].map(el => ({
title: (el as Data1).type || (el as Data2).name,
description: (el as Data1).props.subType || (el as Data2).props.nickName
}))
}
}

or define an object of this interface like

export const presenter: Presenter<ViewData[]> = {
getData: function (): ViewData[] {
return [...DataSource.data1, ...DataSource.data2].map(el => ({
title: (el as Data1).type || (el as Data2).name,
description: (el as Data1).props.subType || (el as Data2).props.nickName
}))
}
}

We also need to define the type ViewData for the view like

export interface ViewData {
title: string
description: string
}

Connect the Model, View, and Presenter

We need to add an instance of the presenter or the presenter object, depends on how we implement the presenter, in the view and call the presenter.getData() function to get the data that the view is needed.

We can update the App function in app.tsx like

const App = (): JSX.Element => {
const isDarkMode = useColorScheme() === 'dark'

const backgroundStyle = {
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter
}

const [data, setData] = useState<ViewData[]>([])

useEffect(() => {
const data = presenter.getData()
setData(data)
}, [])

return (
<SafeAreaView style={backgroundStyle}>
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
backgroundColor={backgroundStyle.backgroundColor}
/>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
style={backgroundStyle}>
<Header />
<View
style={{
backgroundColor: isDarkMode ? Colors.black : Colors.white
}}>
{
data.map((el, index:number) =>
<Section key={index} title={el.title}>
{el.description}
</Section>
)
}
</View>
</ScrollView>
</SafeAreaView>
)
}

This piece of code only demoes the usage of presenter as an object, the usage of class is similar.

Now if we run the app, we will see like

The data from DataSource.data1 and DataSource.data2 of different types are displaying on the view.

Brief speaking, the presenter is responsible to convert the data from the data source to the data for the view. We can also add more data manipulation in the presenter, like grouping, filtering, etc..

Update the view once data changes

One common scenario is the data from the data source keeps changing. It might because the data source is a cloud database or subscribes some push service. So what we should do if we want such data changes trigger the refresh of the view? Let’s try it with MVP architecture.

Firstly, we can add more APIs into the Presenter interface like

export type Callback<T> = (data: T) => void

export interface Presenter<T> {
getData(): T | undefined

setData(data: unknown): void

invalidate(): void

setListener(callback: Callback<T>): void
}

and implement the interface like

export class PresenterImp implements Presenter<ViewData[]> {
data: ViewData[] = []
callback: Callback<ViewData[]> | undefined

constructor() {
this.setData([...DataSource.data1, ...DataSource.data2])
}

getData(): ViewData[] {
return this.data
}

setData(data: unknown): void {
// TODO: more data manipulation
this.data = (data as SourceData[]).map(el => ({
title: (el as Data1).type || (el as Data2).name,
description: (el as Data1).props.subType || (el as Data2).props.nickName
}))
}

invalidate(): void {
this.callback && this.callback(this.data)
}

setListener(callback: Callback<ViewData[]>): void {
this.callback = callback
}
}

for class, and

export const presenter: Presenter<ViewData[]> & Record<keyof any, any> = {
getData(): ViewData[] {
return this.data
},
setData(data: unknown): void {
this.data = (data as SourceData[]).map(el => ({
title: (el as Data1).type || (el as Data2).name,
description: (el as Data1).props.subType || (el as Data2).props.nickName
}))
},
invalidate(): void {
this.callback && this.callback(this.data)
},
setListener(callback: Callback<ViewData[]>): void {
this.callback = callback
}
}

for object.

then we also need to update the useEffect hook in App function like

useEffect(() => {
presenter.setListener(setData)
}, [])

We set the setData function, that would update the state re-render the view, as the parameter of setListener as a callback function. This callback would be executed once the invalidate function is called.

and add a periodical function to DataSource.ts to mock the data source changes as

let count = 0
const dataSource = [...data1, ...data2]
setInterval(() => {
presenter.setData(dataSource.slice(0, count++ % dataSource.length + 1))
presenter.invalidate()
}, 1000)

This periodically running function updates the data source every seconds and it would trigger the refresh of the view by running the callback function.

Launch the app and you would see the list is refreshed every seconds along with the source data changes.

Ok. We have implemented an React Native app based on MVP (Model-View-Presenter) architecture and separated the data and the UI. With the architecture, the UI doesn’t need to be changed even though the syntax / source of data changes.

Pass data between views while navigating

We’ve discussed the simplest scenario, that there is only one view to represent the data from the data source above. Let’s consider a more complicated scenario with two screens, Home and Details.

Add React Navigation

Firstly, we need to add following node modules to support navigation by running

yarn add @react-navigation/native
yarn add react-native-screens react-native-safe-area-context
npx pod-install ios
yarn add @react-navigation/native-stack

just following https://reactnavigation.org/

Refactor the code

Let’s refactor the code first to make the code structure clearer.

  • Update the project struct like
  • Move the App.tsx into home directory and rename it to HomeScreen.tsx, and rename App component as well
  • Put the interface Presenter in a separate file Presenter.ts
  • Rename the class PresentImp to HomePresenter, and rename the object presenter to homePresenter
  • Create new files DetailsPresenter.ts and DetailsScreen.tsx. We will talk about them later
  • Create a new App.tsx and implement it like
import * as React from 'react'
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { StatusBar, useColorScheme } from 'react-native'
import { Colors } from 'react-native/Libraries/NewAppScreen'
import { HomeScreen } from './home/HomeScreen'
import { DetailsScreen } from './details/DetailsScreen'

const Stack = createNativeStackNavigator()

const App = (): JSX.Element => {
const isDarkMode = useColorScheme() === 'dark'

const backgroundStyle = {
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter
}

return (
<NavigationContainer>
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
backgroundColor={backgroundStyle.backgroundColor}
/>
<Stack.Navigator initialRouteName='Home'>
<Stack.Screen name="Home" component={HomeScreen} />
<Stack.Screen name="Details" component={DetailsScreen} />
</Stack.Navigator>

</NavigationContainer>
)
}

export default App

So we defined two screens / views in the App, HomeScreen and DetailsScreen, and the Home is the initial one.

  • Update the index.js as
AppRegistry.registerComponent(appName, () => App)

to make it pointing to the App component as the entry of the app.

Now we have had a app with two screens based on React Navigation, we also need to

Add the navigation from Home screen to Details screen

firstly, we need to make the Section component touchable and implement a onPress function like

const Section: React.FC<
PropsWithChildren<{
data: HomeData
navigation: NavigationProp<any>
}>
> = ({ children, data, navigation }) => {
const isDarkMode = useColorScheme() === 'dark'
return (
<TouchableOpacity onPress={() => {
navigation.navigate('Details')
}}>
<View style={styles.sectionContainer}>
<Text
style={[
styles.sectionTitle,
{
color: isDarkMode ? Colors.white : Colors.black
}
]}>
{data.title}
</Text>
<Text
style={[
styles.sectionDescription,
{
color: isDarkMode ? Colors.light : Colors.dark
}
]}>
{children}
</Text>
</View>
</TouchableOpacity>
)
}

also update the HomeScreen component like

export const HomeScreen = (): JSX.Element => {
const isDarkMode = useColorScheme() === 'dark'

const backgroundStyle = {
backgroundColor: isDarkMode ? Colors.darker : Colors.lighter
}

const navigation = useNavigation()
const [data, setData] = useState<ViewData[]>([])

useEffect(() => {
homePresenter.setListener(setData)
}, [])

return (
<SafeAreaView style={backgroundStyle}>
<StatusBar
barStyle={isDarkMode ? 'light-content' : 'dark-content'}
backgroundColor={backgroundStyle.backgroundColor}
/>
<ScrollView
contentInsetAdjustmentBehavior="automatic"
style={backgroundStyle}>
<Header />
<View
style={{
backgroundColor: isDarkMode ? Colors.black : Colors.white
}}>
{
data.map((el, index:number) =>
<Section key={index} data={el} navigation={navigation}>
{el.description}
</Section>
)
}
</View>
</ScrollView>
</SafeAreaView>
)
}

because we need to know which item in the list has been tapped.

Implement the DetailsScreen

Let’s add some skeleton code of DetailsScreen.tsx as

import * as React from 'react'
import { RouteProp, useRoute } from '@react-navigation/native'
import { useEffect, useRef, useState } from 'react'
import { detailsPresenter } from './DetailsPresenter'
import { StyleSheet, View, Text } from 'react-native'

export interface ViewData {
title: string
fields: Record<string, any>
}

export const DetailsScreen = (): JSX.Element => {

return <View style={styles.container}>
<Text style={styles.sectionTitle}>
Details
</Text>
</View>
}
const styles = StyleSheet.create({
container: {
flexDirection: 'column',
margin: 16,
padding: 8,
paddingHorizontal: 8
},
sectionContainer: {
flexDirection: 'column',
backgroundColor: 'aliceblue',
margin: 8,
padding: 8,
paddingHorizontal: 24
},
sectionTitle: {
fontSize: 24,
fontWeight: '600'
},
sectionDescription: {
marginTop: 8,
fontSize: 18,
fontWeight: '400'
},
highlight: {
fontWeight: '700'
}
})

and DetailsPresenter.ts like

export const detailsPresenter: Presenter<ViewData> & Record<keyof any, any> = {
getData: function (): ViewData | undefined {
return this.data
},
getDataByKey: function (key: string): unknown[] {
return [key === this.data?.key ? this.data : undefined]
},
setData: function (key: string): void {
// TODO: more data manipulation
},
invalidate: function (): void {
this.callback && this.data && this.callback(this.data)
},
setListener: function (callback: Callback<ViewData>): void {
this.callback = callback
}
}

and replace the setInterval part in theDataSource.ts with following code

setTimeout(() => {
homePresenter.setData(dataSource)
homePresenter.invalidate()
}, 1000)

because we don’t want the source data changes in this part.

Now if we run the app, the home screen with a list and the app will navigate to a placeholder details screen if you tap any item on the list.

Now we’d face the key point of designing the multi-screen app with MVP architecture: how to pass the data from one screen to another?

We can absolutely pass the data from one view to another as the route param, or with redux / context, but passing data from view to view breaks the rule that the view can only access data from the presenter and make the data flow messy.

How to resolve this issue? Let’s try following way

  • Giving each source data a unique key
  • Passing through this unique key from source data to view data in the presenter
  • Passing the unique key from one view to another
  • The presenter of second screen fetches the data according to this unique key

There might be other better way to resolve this issue, but we would focus on this unique key approach in this article.

So we need to add the key to all data type, including source data and view data, and we also need to update the mock data with unique key.

and we also need to pass this key from home screen to details screen like

navigation.navigate('Details', { key: data.key })

when we call this navigation function.

And we can also update the DetailsPresenter.ts as

export const detailsPresenter: Presenter<ViewData> & Record<keyof any, any> = {
getData: function (): ViewData | undefined {
return this.data
},
getDataByKey: function (key: string): unknown[] {
return [key === this.data?.key ? this.data : undefined]
},
setData: function (key: string): void {
// TODO: more data manipulation
const sourceData = DataSource.getDataByKey(key)[0] as SourceData
this.data = {
key: sourceData.key,
title: (sourceData as Data1).type || (sourceData as Data2).name,
fields: sourceData.props
}
},
invalidate: function (): void {
this.callback && this.data && this.callback(this.data)
},
setListener: function (callback: Callback<ViewData>): void {
this.callback = callback
}
}

and we can also update the DetailsScreen like

export interface ViewData {
key: string
title: string
fields: Record<string, any>
}

type DetailsScreenParamList = {
data: {
key: string
}
}

export const DetailsScreen = (): JSX.Element => {
const route = useRoute<RouteProp<DetailsScreenParamList>>()
const presenter = useRef(detailsPresenter)
const [data, setData] = useState<ViewData>()

const updateData = (data: ViewData) => {
// TODO: the callback function that would be called in presenter.invalidate()
setData(data)
}

useEffect(() => {
const key = route?.params?.key
presenter.current.setListener(updateData)
presenter.current.setData(key)
presenter.current.invalidate()
}, [])

return <View style={styles.container}>
<Text style={styles.sectionTitle}>
{data?.title}
</Text>
{
data?.fields && Object.keys(data.fields).map(key =>
<View style={styles.sectionContainer} key={key}>
<Text style={styles.sectionTitle}>
{key}
</Text>
<Text style={styles.sectionDescription}>
{data.fields[key]}`
</Text>
</View>
)
}
</View>
}
...

to make it looks prettier.

After all these changes and run the app, the home screen with a list would be popped up first, and if you tap on an item on the list, the app will navigate to the details screen with that item data displayed.

That is all of this article about how to design and React Native app with MVP (Model-View-Presenter) architecture.

--

--