Design React Native app with MVP (Model-View-Presenter) architecture
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.
- 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.
- 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 props
of 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, getData
in 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 toHomeScreen.tsx
, and rename App component as well - Put the interface
Presenter
in a separate filePresenter.ts
- Rename the class
PresentImp
toHomePresenter
, and rename the objectpresenter
tohomePresenter
- Create new files
DetailsPresenter.ts
andDetailsScreen.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.