This is a tutorial for creating a todo list android application using React Native as part of a series of workshops.
Right here.
- Get familiar with git and Github if you aren't already!
- Clone this repo. You can do this by running
git clone https://github.umn.edu/app-developers-club/react-native-todo
. Then you can typecd react-native-todo
and all of this will be in your working directory! - Follow the rest of this tutorial.
- If you think you made a mistake, check out the
src
directory for the completed project.
Make sure you familiarize with the build/test tools as well as the basics of React Native before getting started.
- How do you create a blank project?
- Can you change a headline on news.google.com to make it seem like a headline from The Onion and change its link to rickroll when clicked? (Hint: the
href
attribute of ana
tag controls the destination of a link) - What's the difference between prop and state?
- Create a blank project. You can do this at snack.expo.io, but I recommend doing this through the expo CLI. Working from the command line will make deployment and github integration much easier.
- Open
App.js
, which is the entry point to your app. - Let's start with importing our dependencies
import * as React from 'react';
import { Text, View, FlatList, Button, TextInput, StyleSheet, AsyncStorage, ActivityIndicator } from 'react-native';
- We need to import anything that isn't part of the JavaScript language
- The
*
is pronounced 'wildcard' and means everything in a certain module. - The
as
keyword allows you to give a chosen name to the import. This is called aliasing. - Unless you're using a library like react-native-paper, you can generally find a component's documentation here.
- Entry point boilerplate or: How I learned to stop pasting boilerplate and remember the syntax
export default class App extends React.Component {
...
}
- This part should have already been written for you by the expo CLI or snack
- The
export
statement is used to create a JavaScript module that can then be imported for use in other apps. We could have created another component withexport default class MyComponent extends React.Component
in a file./MyComponent.js
and imported that inApp.js
withimport MyComponent from './MyComponent'
. This is what you would do when creating a larger project. default
makes a more subtle difference. It names the module so that you do not need to give it a name on import like inimport { MyComponent } from './MyComponent
.- All components must extend the
React.Component
interface and implement itsrender()
method.
- Let's implement
render()
export default class App extends React.Component {
render() {
return (
<View>
<Text>Todo List</Text>
<FlatList/>
<TextInput/>
</View>
);
}
}
- Here,
View
is needed as a container since render can only return one JSX element. Think of it as an invisible rectangular container that can hold components. FlatList
will eventually hold the entries of our list.TextInput
will be the user's mechanism of submitting a new entry.
- Create our initial list.
export default class App extends React.Component {
constructor(props) {
super(props);
this.state = {
tasks: [{ text: "Start by adding a task!" }],
text: ""
};
}
render() {
return (
<View>
<Text>Todo List</Text>
<FlatList
data={this.state.tasks}
renderItem={ ({item, index}) =>
<View>
<Text>{item.text}</Text>
</View>
}
/>
<TextInput/>
</View>
);
}
}
- Constructors have a similar function in React as in other programming languages. They serve as initialization methods.
super(props);
is required inside ofconstructor
. - State serves as a mutable data store that can change over time. We will update it as we add new tasks to the list. It's in JSON format, where objects are enclosed with
{}
and havekey:value, key2:value2
pairs while arrays are comma-separated like this:[0,1,2,3]
. FlatList
requires data in the form of an array. You must also implement therenderItem
function as a prop. The function iterates over each element in the array (taking the element's value and index as an argument) and renders a component for it in the list.- When you reference JavaScript code in JSX, it must be surrounded by
{}
. this
is a notoriously confusing keyword. It refers to the class surrounding the current block of code. In this case, the class isApp
which is a component since it extendsReact.Component
.
- Making things pretty.
export default class App extends React.Component {
...
render() {
return (
<View>
<Text style={styles.title}>Todo List</Text>
<FlatList
style={styles.list}
data={this.state.tasks}
renderItem={ ({item, index}) =>
<View style={styles.item}>
<Text style={styles.item}>{item.text}</Text>
</View>
}
/>
<TextInput
style={styles.item}
placeholder="Type to add a task!"
/>
</View>
);
}
}
const styles = StyleSheet.create(
{
list: {
width: '100%'
},
title: {
fontSize: 32,
textAlign: 'center',
margin: 20
},
item: {
padding: 8,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between'
}
}
)
- We use CSS syntax for style sheets.
flex
is a feature in CSS that's heavily used when designing user interfaces for mobile devices. This guide can explain it much better than I can, but it is essentially describing what proportion of the screen a component should cover. For example, if there are two components inside of a<View>
when one hasflex: 2
and the other hasflex: 1
, then they will cover 2/3 and 1/3 of the view, respectively.- The difference between padding and margin is also notoriously confusing. Padding can be described as the distance between the content and the border of a component. Margin can be thought as the distance between the border of a component and the border of another component. The following image illustrates this:
- Notice how we can define a
const
outside of the component and reference it inside.
- Test it out to see your app in action!
- Run
expo start
in the directory that hasApp.js
. This should bring you to a page with the Metro bundler, the tool Facebook created for building and testing React Native apps. Here's what you should see:
- You will know your app is finished building when there's a log saying
Building JavaScript bundle: finished in XXXms
on both the terminal and the browser page. - At this point, you may scan the QR code (using the camera app on iOS or the expo app on Android) and you will see the results in the expo app.
- If you're using snack, you can see the output for the app on the right-hand side.
- Let's start by making sure the text in our
TextInput
updates our app's state.
export default class App extends React.Component {
constructor(props) {
...
}
updateText = (text) => {
this.setState({ text: text });
}
render() {
render (
<View>
...
<TextInput
style={styles.item}
onChangeText={this.updateText}
value={this.state.text}
placeholder="Type to add a task!"
/>
</View>
);
}
}
- The
(param) => { }
syntax is just another way to define a method. We could also writeupdateText(text) { }
. setState
is a method you can use for any React Component. It updates the state. Note that this is different than sayingthis.state = { text: text }
, because initially our state has two keys: tasks (an array of JSON objects) and text (a string). This call tosetState
only updates the value associated with thetext
key.value
andonChangeText
are props ofTextInput
. To make use of them and fully understand how they work, you have to look at the documentation. This is what you'll have to do when you use a component you didn't create from scratch.
- Now that we can keep track of what's in the
TextInput
, we need to handle our app's behavior when the user submits it.
export default class App extends React.Component {
...
updateText = (text) => {
...
}
addTask = () => {
let isEmpty = this.state.text.trim().length == 0;
if (!isEmpty) {
}
}
render() {
...
}
}
let
declares a variable that can be seen inside of its code block. Here, we look atthis.state.text
(what the user typed in theTextInput
and we remove trailing whitespace withtrim()
then count how many characters are in the string. If the string is empty, then there will be 0 characters and the comparison with 0 will yieldtrue
.!
is pronounced 'bang'. It inverts a Boolean variable (one that istrue
orfalse
) likeisEmpty
. IfisEmpty
istrue
, then!isEmpty
isfalse
and vice versa.- We don't want to add a task if the user submits nothing or submits a bunch of spaces. That's why we have this
if
statement.
- We need to add functionality for adding tasks
...
addTask = () => {
let isEmpty = this.state.text.trim().length == 0;
if (!isEmpty) {
this.setState(
(prevState) => {
let { tasks, text } = prevState;
return {
tasks: tasks.concat({ index: tasks.length, text: text }),
text: ""
};
}
);
}
}
...
- There's a lot going on here. At the top level, we're using
setState
again, but not in the way we used before. Previously, we gave it a JSON object and it would update the state. This time we give it a function which it will evaluate by passing it the previous state. Note that the choice of naming the parameter of the functionprevState
doesn't change the behavior of the program. We could have named the parameterCharles
andsetState
will still give our function the previous state ofApp
. - One level down, we define our function.
prevState
is a JavaScript object with two keys. Using the nifty syntaxlet { tasks, text } = prevState;
, we can 'unpack' that object so we have two variables in our scope: one for each key. - Finally, we have the return statement of our function. The output of our function should be a JavaScript object so that
setState
can update our app's state. - We assign a value to the
tasks
key:tasks: tasks.concat({ index: tasks.length, text: text })
. Thetasks
on the right-hand side refers to the variable we created when we 'unpacked' the previous state. Thetext
on the right-hand side is the other variable created when unpacking. So the value that ends up being associated with thetasks
key is just the previous state with another object added (concatenated) to the end of the array. We want theindex
of the new object to be equal to the number of tasks above it so that the 2nd element is at index 1 and so on. - We still need to update what's rendered in our app.
render() {
return (
<View>
...
<TextInput
...
onSubmitEditing={this.addTask}
returnKeyLabel="done"
returnKeyType="done"
/>
</View>
);
}
returnKeyLabel
andreturnKeyType
are simply to make theTextInput
compatible with both Android and iOS. "done" just happens to be an action both platforms support.
- Let's implement removing tasks.
...
addTask = () => {
...
}
deleteTask = i => {
this.setState(
(prevState) => {
let newState = prevState.tasks.slice();
newState.splice(i, 1);
return { tasks: newState };
}
);
}
render() {
...
}
- We're using the same approach as
addTask
: givingsetState
a function to evaluate given the previous state. slice
gives us a copy of thetasks
array so that we can modify it before returning.splice
removes 1 element at indexi
.- We update the state with the list of tasks minus the one we just deleted
- The user still needs a way to delete a specific task
render() {
return (
...
<FlatList
renderItem = {
({ item, index }) =>
<View style={styles.item}>
<Text style={styles.item}>{item.text}</Text>
<Button title="x" onPress={ () => this.deleteTask(index) } />
</View>
}
/>
...
);
}
And we're done! Test the app out. It should look like this when it's in action.
- Todo lists aren't very useful if they go away after you exit the app.
- Let's add some memory to the app that persists between sessions.
- We'll use AsyncStorage for this.
- The 'Async' part of AsyncStorage stands for asynchronous. That means there may be some time delay between when its methods are called and when it will return a response. JavaScript has its own way of dealing with asynchronicity, so we're going to have to learn some new syntax.
- AsyncStorage is local to the device running the app and every component can see its contents.
- It organizes its data with key-value pairs, where both of them are strings.
- Let's make it so that the app pulls from AsyncStorage every time the user opens the app.
constructor(props) {
super(props);
this.state = {
...
loaded: false
};
}
...
deleteTask = i => {
...
}
async componentDidMount() {
const items = await AsyncStorage.getItem("TASKS");
if (items !== null) {
this.setState({tasks: JSON.parse(items)});
}
this.setState({loaded: true});
);
}
render() {
if (!this.state.loaded) {
return <ActivityIndicator/>
}
...
}
- We need to prefix each asynchronous function with the keyword
async
. This allows us to use the keywordawait
, which waits until an asynchronous call can resolve to a value. componentDidMount
is another special method for components besidesrender
. It is called after the constructor and is able to be asynchronous.- Since
componentDidMount
is asynchronous andrender
is not, we need to make sure that the incorrect data is not being rendered before the data is updated incomponentDidMount
. We do this by having aloaded
key in our state that isfalse
by default and is only madetrue
whencomponentDidMount
is finished. In the time thatcomponentDidMount
takes to finish,render
will simply display a loading wheel because ofActivityIndicator
. - Note that we reference the key
"TASKS"
before we ever assign a value to that key. When the user opens the app for the first time, the value associated with"TASKS"
will benull
or empty. We only update our data if"TASKS"
has a non-null value. AsyncStorage.getItem
takes two arguments. The first is which key to look for its value. The second is a function to call when the lookup is complete. That function, called a callback, could handle errors, but we won't be doing that in this rather simple app.- This call to
JSON.parse
is a preview of what we will be doing next.JSON.parse
takes a JSON string and converts it to an object. This bridges the gap between AsyncStorage, which wants a string key to store a string value, andsetState
, which takes an object. - Now let's actually allow
"TASKS"
to have a value that can be changed by the user.
addTask = () => {
let isEmpty = this.state.text.trim().length == 0;
if (!isEmpty) {
this.setState(
(prevState) => {
...
},
async () => await AsyncStorage.setItem("TASKS", JSON.stringify(this.state.tasks))
);
}
}
deleteTask = i => {
this.setState(
(prevState) => {
...
},
async () => await AsyncStorage.setItem("TASKS", JSON.stringify(this.state.tasks))
);
}
- Here, we're using an optional argument for
setState
: a function to call after setting the state. The function is another example of a callback. - Our callback is asynchronous and is what's updating AsyncStorage with the tasks being added and deleted. Since
state
is an object, we need to callJSON.stringify
to turn it in a string so that AsyncStorage can store it. AsyncStorage.setItem
takes two arguments: the string key and the string value with to which associate it- Now, when the user adds or deletes a task, AsyncStorage will see this and update its data. Remembering back to what we did earlier with
componentDidMount
, the value associated with"TASK"
will no longer benull
. When the user closes the app and opens it again,componentDidMount
will see that there is saved data under"TASKS"
and change what will eventually be rendered for the user. - Try looking at the code we just wrote and see if you can find which line support the each step of reasoning in the above bullet point.
- That's it! We now have a working todo list app that's actually usable!
- Make sure the app works by running
expo start
and trying it out on your phone. You can also import your code to snack.expo.io to test on android if you don't have an android device. - Follow the steps of Expo's documentation on building and uploading, and deploying. You should consult these if something goes wrong.
- A previous version of this guide used the
expo build
system; please use the EAS CLI build system instead. - Enjoy your todo list as well as your new React Native skills and knowledge!
Adapted from todo app with react native
Last updated: 02/23/2022
Run git pull
to update