๐ฑ Component Driven Development with Storybook React Native
๐ Introduction
Welcome to this comprehensive, beginner-friendly guide on component-driven development using Storybook UI in React Native. This tutorial leverages the power of Expo, Tailwind and Storybook to provide a robust learning experience. GitHub Repo: https://github.com/FastheDeveloper/RN_ComponentDrivenDevelopment_Storybook
๐ฏ What You'll Learn
Core principles of component-driven development
Harnessing Storybook UI to enhance your development workflow
Bootstrapping a React Native project with Tailwind
Seamlessly integrating Storybook UI into your Expo project
Crafting and showcasing reusable components
Industry best practices for component-driven development
๐ Prerequisites
Before diving in, make sure you have:
A basic understanding of JavaScript, Typescript and React Native
Node.js and npm installed on your development machine
Make sure you have completed the React Native - Environment Setup instructions
Your preferred code editor ready to go
๐ Tutorial Sections
Introduction to Component-Driven Development
Setting Up Your Expo Project
Integrating Storybook UI
Creating Your First Component
Crafting a Dynamic Button Component
Creating a Default Button Story
Creating Various Button Stories
Writing Jest Tests for the Button Component
Creating a Dynamic TextInput Component
Crafting a TextInput with Tailwind CSS
Creating a Default TextInput Story
Creating Various TextInput Stories
Writing Jest Tests for the TextInput Component
Testing Your Components
More Component Examples
Implementing the TextInput and Button Component
Introduction to Component-Driven Development in React Native
Component-Driven Development (CDD) is an approach to building user interfaces that emphasizes creating applications from the "bottom up" using reusable components. This methodology is particularly well-suited for React Native development, given the framework's component-based architecture.
What is Component-Driven Development?
CDD is a development methodology that focuses on building user interfaces by breaking them down into smaller, reusable components. These components are developed in isolation before being assembled into larger features and, ultimately, complete pages or screens.
Key principles of CDD include:
Modularity: Building UIs from discrete, reusable components.
Isolation: Developing and testing components independently.
Composition: Combining smaller components to create larger ones.
Reusability: Designing components for use across different parts of an application.
Benefits for React Native Developers
Adopting CDD in React Native development offers several advantages:
Improved Maintainability: Smaller, self-contained components are easier to understand, update, and debug.
Enhanced Reusability: Well-designed components can be reused across different parts of your app or even in different projects.
Easier Collaboration: Teams can work on different components simultaneously without conflicts.
Consistent Design: Using a library of standard components ensures UI consistency throughout the app.
Efficient Testing: Components can be tested in isolation, making it easier to write and maintain unit tests.
Implementing CDD in React Native
To implement CDD in your React Native project:
Start Small: Begin by identifying and building the smallest, most basic UI elements (buttons, inputs, etc.).
Build a Component Library: Create a collection of reusable components that can be shared across your application.
Use Tools: Leverage tools like Storybook to develop and showcase your components in isolation.
Document Components: Provide clear documentation for each component, including its props, usage examples, and any limitations.
Test Components: Write unit tests for each component to ensure they function correctly in isolation.
By adopting CDD, React Native developers can create more maintainable, scalable, and consistent mobile applications. This approach aligns well with React Native's philosophy and can significantly improve development efficiency and code quality.
Getting Started
Using My Repository
To quickly get started with the pre-configured environment, follow these steps:
- Clone the repository using:
git clone https://github.com/FastheDeveloper/RN_ComponentDrivenDevelopment_Storybook.git
Navigate to the project directory and install the dependencies by running:
yarn # or npm install
Setting Up from Scratch
Creating your expo react native application:
npx create-expo-stack@latest
Running the above command will create a boilerplate application after asking a few configuration questions.
Setup Storybook In The Project:
npx storybook@latest init
This will add a new folder (.storybook) in your project directory
Additional Configuration:
- Create an app.config.js File This configuration facilitates easy switching between Storybook UI for testing and the actual React Native application. This file defines the storybookEnabled constant based on the environment variable STORYBOOK_ENABLED. This helps determine whether to render Storybook or the main application.
export default ({ config }) => ({
...config,
name: 'My_App_Name',
slug: 'My_App_Name',
extra: {
storybookEnabled: process.env.STORYBOOK_ENABLED,
},
});
Update package.json File In the package.json file, add these Storybook scripts. We use these to pass that environment variable to our application, which swaps the entry point to the Storybook UI using cross-env to make sure this works on all platforms (Windows/macOS/Linux).
{ "scripts": { "storybook": "cross-env STORYBOOK_ENABLED='true' expo start", "storybook:ios": "cross-env STORYBOOK_ENABLED='true' expo ios", "storybook:android": "cross-env STORYBOOK_ENABLED='true' expo android" } }
- Setup Entry point (/app/index.tsx) with the env variable:
import React from 'react'
import { StyleSheet, Text, View } from 'react-native'
import Constants from 'expo-constants'
function Page() {
return (
<View style={styles.container}>
<View style={styles.main}>
<Text className="text-white">This is the first page of your app.</Text>
</View>
</View>
)
}
let AppEntryPoint = Page
if (Constants?.expoConfig?.extra?.storybookEnabled === 'true') {
const StorybookUI = require('../.storybook').default
AppEntryPoint = () => {
return (
<View style={{ flex: 1 }}>
<StorybookUI />
</View>
)
}
}
export default AppEntryPoint
- Run the App for Testing:
To test the app, you can use one of the following commands depending on your target platform:
yarn ios
#or
yarn android
Run Storybook to test:
yarn storybook:ios #or yarn storybook:android
Creating a Dynamic Button Component
Create Button with StyleSheet/Tailwind css, adding Jest testID to the pressable
import { ActivityIndicator, Pressable, StyleSheet, Text, View } from 'react-native'
import React, { ComponentProps } from 'react'
import { APP_COLOR } from '@constants/colorConstants'
import { FontAwesome } from '@expo/vector-icons'
type buttonProps = {
loading?: boolean
rightIcon?: keyof typeof FontAwesome.glyphMap
leftIcon?: keyof typeof FontAwesome.glyphMap
label: string
} & ComponentProps<typeof Pressable>
const AppButton = ({ loading, leftIcon, label, rightIcon, ...pressableProps }: buttonProps) => {
const content = loading ? (
<>
<View
// style={styles.loaderWrapper}
className="justify-center h-6"
>
<ActivityIndicator size="small" color={'white'} animating={true} />
</View>
</>
) : (
<>
{leftIcon && (
<View
//style={styles.leftIcon}
className="absolute left-5"
>
<FontAwesome name={leftIcon} size={20} />
</View>
)}
<Text
className="text-APP_COLOR-MAIN_WHITE text-center text-lg font-bold "
//style={styles.buttonText}
>
{label}
</Text>
{rightIcon && (
<View
// style={styles.rightIcon}
className="absolute right-5"
>
<FontAwesome name={rightIcon} size={20} />
</View>
)}
</>
)
return (
<Pressable
//style={styles.button}
className="w-full flex justify-center item-center bg-APP_COLOR-MAIN_GREEN rounded-3xl p-4 shadow-lg"
{...pressableProps}
testID="testClick"
>
{content}
</Pressable>
)
}
export default AppButton
const styles = StyleSheet.create({
button: {
alignItems: 'center',
backgroundColor: APP_COLOR.MAIN_GREEN,
borderRadius: 24,
elevation: 5,
flexDirection: 'row',
justifyContent: 'center',
padding: 16,
width: '100%',
shadowColor: '#000',
shadowOffset: {
height: 2,
width: 0,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
},
buttonText: {
color: APP_COLOR.MAIN_WHITE,
fontSize: 18,
fontWeight: '700',
textAlign: 'center',
},
loaderWrapper: {
height: 24,
justifyContent: 'center',
},
rightIcon: {
position: 'absolute',
right: 20,
},
leftIcon: {
position: 'absolute',
left: 20,
},
})
Creating Default Button Story
In our story files, we use a syntax called Component Story Format (CSF). In this case, we are using CSF3, which is a newer, updated version of CSF supported by the latest versions of Storybook. This version of CSF has significantly less boilerplate, making it easier to get started. In Storybook, there are two basic levels of organization: the component and its child stories. Each story can be thought of as a permutation of a component. You can have as many stories per component as needed.
Component
Story
Story
Story
To introduce the component we are documenting, we create a default export that contains:
component - the component itself
title - how to refer to the component in the Storybook app's sidebar
argTypes - allows us to specify the types of our args, here we use it to define actions that will log whenever that interaction takes place
import type { Meta, StoryObj } from '@storybook/react' import AppButton from './AppButton' import React from 'react' import { View } from 'react-native' const AppButtonMeta: Meta<typeof AppButton> = { title: 'Button', component: AppButton, argTypes: { onPress: { action: 'pressed the button' }, }, args: { label: 'Story Button', loading: false, }, decorators: [ (Story) => ( <View style={{ alignItems: 'center', justifyContent: 'center', flex: 1 }}> <Story /> </View> ), ], } export default AppButtonMeta export const Default: StoryObj<typeof AppButton> = {}
In this example, we create a new default story, which tells Storybook that:
The name in the sidebar should be "Button"
The component it should be attached to is AppButton
The default label should be "Story Button"
The default loading state should be false
The onPress action should run the 'pressed the button' action
Creating Various Button Stories
export const TextOnlyButton: StoryObj<typeof AppButton> = {
args: {
label: 'Text Button',
},
argTypes: {
onPress: { action: 'Yaay' },
},
parameters: {
noBackground: true,
},
}
export const WithLeftIcon: StoryObj<typeof AppButton> = {
args: {
label: 'With Left Icon',
leftIcon: 'paper-plane',
},
argTypes: {
onPress: { action: 'Lefty Pressed' },
},
parameters: {
noBackground: true,
},
}
export const WithRightIcon: StoryObj<typeof AppButton> = {
args: {
label: 'With Right Icon',
rightIcon: 'user-circle-o',
},
argTypes: {
onPress: { action: 'Righty Pressed' },
},
parameters: {
noBackground: true,
},
}
export const WithBothIcons: StoryObj<typeof AppButton> = {
args: {
label: 'With Both Icons',
rightIcon: 'user-circle-o',
leftIcon: 'paper-plane',
},
argTypes: {
onPress: { action: 'Bothy Pressed' },
},
parameters: {
noBackground: true,
},
}
Creating the Jest Test
import React from 'react'
import { render, fireEvent } from '@testing-library/react-native'
import AppButton from '@/components/Button/AppButton'
describe('MyButtons', () => {
it('calls Unpressed when clicked', () => {
const mockOnPress = jest.fn()
const { getByTestId } = render(<AppButton label="Test" onPress={mockOnPress} />)
const pressMeButton = getByTestId('testClick')
fireEvent.press(pressMeButton)
expect(mockOnPress).toHaveBeenCalled()
})
})
Test Suite Setup:
- We define a test suite named AppButton Component using describe. This groups together related tests for the AppButton component.
Test Case Definition:
- Inside the test suite, there is a single test case defined using the it function. The test case is titled "calls Unpressed when clicked".
Mock Function Creation:
- We create a mock function mockOnPress using jest.fn(). This mock function simulates the onPress prop of the AppButton component to test if it gets called when the button is pressed.
Rendering the Component:
- We render the AppButton component with a label prop set to "Test" and the onPress prop set to mockOnPress using the render function from @testing-library/react-native.
Simulating User Interaction:
- We retrieve the button element using getByTestId with the test ID "testClick". We simulate a press event on the button element using fireEvent.press
Verifying Behavior:
- We assert that the mockOnPress function has been called using expect(mockOnPress).toHaveBeenCalled(). This confirms that the onPress prop function was triggered when the button was pressed.
Running the Jest Test
yarn test
To execute the test case, use the above command:
Creating a Dynamic TextInput Component
Create Textinput with Tailwind css, adding Jest testID
import { Pressable, Text, TextInput, View } from 'react-native'
import React, { ComponentProps, useState } from 'react'
import { FontAwesome } from '@expo/vector-icons'
type buttonProps = {
rightIcon?: keyof typeof FontAwesome.glyphMap
leftIcon?: keyof typeof FontAwesome.glyphMap
label?: string
} & ComponentProps<typeof TextInput>
const InputField = ({ leftIcon, label, rightIcon, ...inputProps }: buttonProps) => {
const [hide, setHide] = useState(true)
return (
<View className="mx-2">
<Text className="text-lg font-bold mb-2">{label}</Text>
<View className="flex-row justify-between items-center bg-APP_COLOR-MAIN_WHITE w-full mt-2 rounded-xl shadow-xl border border-APP_COLOR-MAIN_GREY ">
{leftIcon && (
<View className="ml-2" testID="left-icon">
<FontAwesome name={leftIcon} size={20} />
</View>
)}
<TextInput
className={`h-[52px] w-[85%] px-[2%] `}
{...inputProps}
secureTextEntry={inputProps.secureTextEntry && !rightIcon ? hide : undefined}
testID="text-input"
/>
{(rightIcon || inputProps.secureTextEntry) && (
<View className="mr-2" testID="right-icon">
<Pressable onPress={() => (rightIcon ? null : setHide(!hide))} testID="passwordTest">
<FontAwesome
name={rightIcon || (inputProps.secureTextEntry && (hide ? 'eye' : 'eye-slash')) || undefined}
size={20}
/>
</Pressable>
</View>
)}
</View>
</View>
)
}
export default InputField
Creating Default TextInput Story
import type { Meta, StoryObj } from '@storybook/react'
import React from 'react'
import { View } from 'react-native'
import InputField from './InputField'
const InputFieldMeta: Meta<typeof InputField> = {
title: 'Input Field',
component: InputField,
argTypes: {},
args: {
label: 'Story Input',
},
decorators: [
(Story) => (
<View style={{ justifyContent: 'center', flex: 1 }}>
<Story />
</View>
),
],
}
export default InputFieldMeta
export const Default: StoryObj<typeof InputField> = {}
In this example, we create a new default story, which tells Storybook that:
The name in the sidebar should be "Input Field"
The component it should be attached to is InputField
The default label should be "Story Input"
Creating Various Textinput Stories
export const PasswordInput: StoryObj<typeof InputField> = {
args: {
secureTextEntry: true,
},
}
export const LeftIconInput: StoryObj<typeof InputField> = {
args: {
leftIcon: 'user-circle',
},
}
export const RightIconInput: StoryObj<typeof InputField> = {
args: {
rightIcon: 'star',
},
}
export const SafetyIconWithSecureEntry: StoryObj<typeof InputField> = {
args: {
rightIcon: 'star',
secureTextEntry: true,
},
}
Creating the Jest Test
import React from 'react'
import { render, fireEvent } from '@testing-library/react-native'
import InputField from '@/components/InputField/InputField'
describe('InputField', () => {
it('renders correctly with label', () => {
const { getByText } = render(<InputField label="Test Label" />)
expect(getByText('Test Label')).toBeTruthy()
})
it('renders left icon when provided', () => {
const { getByTestId } = render(<InputField leftIcon="user" />)
expect(getByTestId('left-icon')).toBeTruthy()
})
it('renders right icon when provided', () => {
const { getByTestId } = render(<InputField rightIcon="lock" />)
expect(getByTestId('right-icon')).toBeTruthy()
})
it('toggles password visibility when secureTextEntry is true', () => {
const { getByTestId } = render(<InputField secureTextEntry />)
const passwordToggle = getByTestId('passwordTest')
const input = getByTestId('text-input')
expect(input.props.secureTextEntry).toBe(true)
fireEvent.press(passwordToggle)
expect(input.props.secureTextEntry).toBe(false)
fireEvent.press(passwordToggle)
expect(input.props.secureTextEntry).toBe(true)
})
it('passes additional props to TextInput', () => {
const { getByTestId } = render(<InputField placeholder="Enter text" />)
const input = getByTestId('text-input')
expect(input.props.placeholder).toBe('Enter text')
})
})
Testing
This component is tested using Jest and React Native Testing Library. The tests cover various aspects of the InputField component's functionality:
Rendering with label: Ensures that the component correctly renders the provided label text.
Left icon rendering: Verifies that the left icon is displayed when the
leftIcon
prop is provided.Right icon rendering: Checks if the right icon is shown when the
rightIcon
prop is given.Password visibility toggle: Tests the functionality of toggling password visibility when
secureTextEntry
is true. It verifies that:The input is initially secure (password hidden)
Pressing the toggle button makes the input visible
Pressing the toggle button again makes the input secure
Prop passing: Confirms that additional props (like
placeholder
) are correctly passed to the TextInput component.
The tests use render
from React Native Testing Library to render the component, and fireEvent
to simulate user interactions. getByText
and getByTestId
are used to query elements in the rendered component.
Running the Jest Test
yarn test
To execute the test case, use the above command:
Component Examples
For a more comprehensive understanding of how to use this component and to explore additional implementations, please refer to the component
folder in the project directory. This folder contains various examples that demonstrate different use cases and configurations Components and Storybook React Native
These examples can serve as practical references,
By examining these examples, you'll gain insights into creating various components using Storybook react native
Building a Basic Sign-Up Screen with Our Components
import { Text, View } from 'react-native'
import React, { useState } from 'react'
import InputField from '@/components/InputField/InputField'
import AppButton from '@/components/Button/AppButton'
import { validateEmail, passwordsMatch, allFieldsFilled } from '../utils/Validators'
export interface UserDetails {
name: string
email: string
password: string
confirmPassword: string
}
const initialUserDetails: UserDetails = {
name: '',
email: '',
password: '',
confirmPassword: '',
}
const SignupScreen = () => {
const [userDetails, setUserDetails] = useState<UserDetails>(initialUserDetails)
const handleChange = (name: keyof UserDetails, value: string) => {
setUserDetails((prevDetails) => ({
...prevDetails,
[name]: value,
}))
}
const isFormValid = () => {
return (
validateEmail(userDetails.email) &&
passwordsMatch(userDetails.password, userDetails.confirmPassword) &&
allFieldsFilled(userDetails)
)
}
return (
<View className=" flex-1 mx-4">
<View className=" items-center ">
<Text className="text-lg text-[#212529] font-bold text-2xl">Let's Gets Signed Up</Text>
<Text className="text-[#A3AAB0]"> Create a new account</Text>
</View>
<View>
<InputField
leftIcon="user-o"
placeholder="Enter your name"
onChangeText={(text) => handleChange('name', text)}
/>
<InputField
leftIcon="envelope-o"
placeholder="Enter your email"
rightIcon={validateEmail(userDetails.email) ? 'check' : undefined}
onChangeText={(text) => handleChange('email', text)}
/>
<InputField
leftIcon="lock"
placeholder="Enter your Password"
secureTextEntry
onChangeText={(text) => handleChange('password', text)}
/>
<InputField
leftIcon="lock"
placeholder="Re-Enter your Password"
secureTextEntry
onChangeText={(text) => handleChange('confirmPassword', text)}
/>
</View>
<View className="flex-1 justify-end mx-2">
<AppButton label="Sign Up" disabled={!isFormValid()} />
</View>
</View>
)
}
export default SignupScreen
This basic usage demonstrates how component-driven development with Storybook enhances the reusability of components that are created in isolation and tested with Jest. It provides confidence in their performance and functionality, showcasing the benefits of using Storybook for building and validating components.
Happy coding! ๐๐๐
Subscribe to my newsletter
Read articles from Farouq Seriki directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by