Building Chatty — Part 3: GraphQL Queries with React Apollo
A WhatsApp clone with React Native and Apollo
This is the third blog in a multipart series where we will be building Chatty, a WhatsApp clone, using React Native and Apollo. You can view the code for this part of the series here.
Here’s what we will accomplish in this tutorial:
- Create the basic layout for our React Native application
- Build GraphQL Queries using
react-apollo
First we’re going to need a few packages:
# make sure to add these packages to client folder!!!
cd clientnpm i lodash moment graphql-tag prop-types randomcolor react-navigation
lodash
is a top notch utility library for dealing with data in JSmoment
is the goto package for dealing with Dates in JSgraphql-tag
lets us parse GraphQL Queries from stringsprop-types
is a runtime type checker for React props and similar objectsrandomcolor
will let us generate random colorsreact-navigation
is a collaboration between people from Facebook, Exponent and the React community to create the best navigation solution for the React ecosystem
Creating the Layout
I’m a visual guy, so I like to first establish the general look and feel of the app before adding data.
Using react-navigation
, we can design routing for our application based on Screens. A Screen is essentially a full screen or collection of sub-screens within our application. It’s easiest to understand with a basic example.
Let’s create a new file client/src/navigation.js
where we will declare the navigation for our application:
Step 3.1: Create AppWithNavigationState
Changed client/package.json
... "apollo-link-redux": "^0.2.1",
"graphql": "^0.12.3",
"graphql-tag": "^2.4.2",
"lodash": "^4.17.5",
"moment": "^2.20.1",
"prop-types": "^15.6.0",
"randomcolor": "^0.5.3",
"react": "16.4.1",
"react-apollo": "^2.0.4",
"react-native": "0.56.0",
"react-navigation": "^1.0.3",
"react-navigation-redux-helpers": "^1.1.2",
"react-redux": "^5.0.5",
"redux": "^3.7.2",
"redux-devtools-extension": "^2.13.2"
Added client/src/navigation.js
...import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { NavigationActions, addNavigationHelpers, StackNavigator, TabNavigator } from 'react-navigation';
import {
createReduxBoundAddListener,
createReactNavigationReduxMiddleware,
} from 'react-navigation-redux-helpers';
import { Text, View, StyleSheet } from 'react-native';
import { connect } from 'react-redux';const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'white',
},
tabText: {
color: '#777',
fontSize: 10,
justifyContent: 'center',
},
selected: {
color: 'blue',
},
});const TestScreen = title => () => (
<View style={styles.container}>
<Text>
{title}
</Text>
</View>
);// tabs in main screen
const MainScreenNavigator = TabNavigator({
Chats: { screen: TestScreen('Chats') },
Settings: { screen: TestScreen('Settings') },
}, {
initialRouteName: 'Chats',
});const AppNavigator = StackNavigator({
Main: { screen: MainScreenNavigator },
});// reducer initialization code
const initialState=AppNavigator.router.getStateForAction(NavigationActions.reset({
index: 0,
actions: [
NavigationActions.navigate({
routeName: 'Main',
}),
],
}));export const navigationReducer = (state = initialState, action) => {
const nextState = AppNavigator.router.getStateForAction(action, state); // Simply return the original `state` if `nextState` is null or undefined.
return nextState || state;
};// Note: createReactNavigationReduxMiddleware must be run before createReduxBoundAddListener
export const navigationMiddleware = createReactNavigationReduxMiddleware(
"root",
state => state.nav,
);
const addListener = createReduxBoundAddListener("root");class AppWithNavigationState extends Component {
render() {
return (
<AppNavigator navigation={addNavigationHelpers({
dispatch: this.props.dispatch,
state: this.props.nav,
addListener,
})} />
);
}
}const mapStateToProps = state => ({
nav: state.nav,
});export default connect(mapStateToProps)(AppWithNavigationState);
This setup will create a StackNavigator
named AppNavigator
that will hold all our Screens. A StackNavigator
stacks Screens on top of each other like pancakes when we navigate to them.
Within AppNavigator
, we can add different Screens and other Navigators that can be pushed onto the stack.
MainScreenNavigator
is a TabNavigator
, which means it organizes Screens in tabs. The Screens within MainScreenNavigator are just placeholders which display the title of the Screen for now. MainScreenNavigator
will be our default view, so we've added it as the first Screen in AppNavigator
.
We also have created a basic reducer for our navigator (navigatorReducer
) to track navigation actions in Redux. We use connect
from react-redux
to connect our AppNavigator to Redux.
We can update app.js
to use our new Redux connected AppWithNavigationState
component and combine navigationReducer
with our apollo
reducer:
Step 3.2: Connect Navitgation to App
Changed client/src/app.js
...import React, { Component } from 'react';import { ApolloClient } from 'apollo-client';
import { ApolloLink } from 'apollo-link';
import { ApolloProvider } from 'react-apollo';
import { composeWithDevTools } from 'redux-devtools-extension';
import { createHttpLink } from 'apollo-link-http';
import { createStore, combineReducers, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import { ReduxCache, apolloReducer } from 'apollo-cache-redux';
import ReduxLink from 'apollo-link-redux';
import { onError } from 'apollo-link-error';import AppWithNavigationState, {
navigationReducer,
navigationMiddleware,
} from './navigation';const URL = 'localhost:8080'; // set your comp's url hereconst store = createStore(
combineReducers({
apollo: apolloReducer,
nav: navigationReducer,
}),
{}, // initial state
composeWithDevTools(
applyMiddleware(navigationMiddleware),
),
);const cache = new ReduxCache({ store });... cache,
});export default class App extends Component {
render() {
return (
<ApolloProvider client={client}>
<Provider store={store}>
<AppWithNavigationState />
</Provider>
</ApolloProvider>
);
}
}
Refresh the app to see some simple tabs:
On the Chats
tab, we want to show a list of the user’s groups. We’ll create a Groups
screen component in a new file client/src/screens/groups.screen.js
:
Step 3.3: Create Groups Screen
Added client/src/screens/groups.screen.js
...import { _ } from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import {
FlatList,
StyleSheet,
Text,
TouchableHighlight,
View,
} from 'react-native';const styles = StyleSheet.create({
container: {
backgroundColor: 'white',
flex: 1,
},
groupContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'white',
borderBottomColor: '#eee',
borderBottomWidth: 1,
paddingHorizontal: 12,
paddingVertical: 8,
},
groupName: {
fontWeight: 'bold',
flex: 0.7,
},
});// create fake data to populate our ListView
const fakeData = () => _.times(100, i => ({
id: i,
name: `Group ${i}`,
}));class Group extends Component {
render() {
const { id, name } = this.props.group;
return (
<TouchableHighlight
key={id}
>
<View style={styles.groupContainer}>
<Text style={styles.groupName}>{`${name}`}</Text>
</View>
</TouchableHighlight>
);
}
}Group.propTypes = {
group: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,
}),
};class Groups extends Component {
static navigationOptions = {
title: 'Chats',
}; keyExtractor = item => item.id.toString(); renderItem = ({ item }) => <Group group={item} />; render() {
// render list of groups for user
return (
<View style={styles.container}>
<FlatList
data={fakeData()}
keyExtractor={this.keyExtractor}
renderItem={this.renderItem}
/>
</View>
);
}
}export default Groups;
And insert Groups
into our AppNavigator
:
Step 3.3: Create Groups Screen
Changed client/src/navigation.js
...import { Text, View, StyleSheet } from 'react-native';
import { connect } from 'react-redux';import Groups from './screens/groups.screen';const styles = StyleSheet.create({
container: {
flex: 1,...
// tabs in main screen
const MainScreenNavigator = TabNavigator({
Chats: { screen: Groups },
Settings: { screen: TestScreen('Settings') },
}, {
initialRouteName: 'Chats',
When the user presses one of the rows in our FlatList
, we want to push a new Screen with the message thread for the selected group. For this Screen, we’ll create a new Messages
component which will hold a list of Message
components:
Step 3.4: Create Messages Screen
Added client/src/screens/messages.screen.js
...import { _ } from 'lodash';
import {
FlatList,
StyleSheet,
View,
} from 'react-native';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import randomColor from 'randomcolor';import Message from '../components/message.component';const styles = StyleSheet.create({
container: {
alignItems: 'stretch',
backgroundColor: '#e5ddd5',
flex: 1,
flexDirection: 'column',
},
});const fakeData = () => _.times(100, i => ({
// every message will have a different color
color: randomColor(),
// every 5th message will look like it's from the current user
isCurrentUser: i % 5 === 0,
message: {
id: i,
createdAt: new Date().toISOString(),
from: {
username: `Username ${i}`,
},
text: `Message ${i}`,
},
}));class Messages extends Component {
static navigationOptions = ({ navigation }) => {
const { state } = navigation;
return {
title: state.params.title,
};
}; keyExtractor = item => item.message.id.toString(); renderItem = ({ item: { isCurrentUser, message, color } }) => (
<Message
color={color}
isCurrentUser={isCurrentUser}
message={message}
/>
) render() {
// render list of messages for group
return (
<View style={styles.container}>
<FlatList
data={fakeData()}
keyExtractor={this.keyExtractor}
renderItem={this.renderItem}
ListEmptyComponent={<View />}
/>
</View>
);
}
}export default Messages;
We’ll also write the code for the individual Message
components that populate the FlatList
in Messages
:
Step 3.5: Create Message Component
Added client/src/components/message.component.js
...import moment from 'moment';
import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import {
StyleSheet,
Text,
View,
} from 'react-native';const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
},
message: {
flex: 0.8,
backgroundColor: 'white',
borderRadius: 6,
marginHorizontal: 16,
marginVertical: 2,
paddingHorizontal: 8,
paddingVertical: 6,
shadowColor: 'black',
shadowOpacity: 0.5,
shadowRadius: 1,
shadowOffset: {
height: 1,
},
},
myMessage: {
backgroundColor: '#dcf8c6',
},
messageUsername: {
color: 'red',
fontWeight: 'bold',
paddingBottom: 12,
},
messageTime: {
color: '#8c8c8c',
fontSize: 11,
textAlign: 'right',
},
messageSpacer: {
flex: 0.2,
},
});class Message extends PureComponent {
render() {
const { color, message, isCurrentUser } = this.props; return (
<View key={message.id} style={styles.container}>
{isCurrentUser ? <View style={styles.messageSpacer} /> : undefined }
<View
style={[styles.message, isCurrentUser && styles.myMessage]}
>
<Text
style={[
styles.messageUsername,
{ color },
]}
>{message.from.username}</Text>
<Text>{message.text}</Text>
<Text style={styles.messageTime}>{moment(message.createdAt).format('h:mm A')}</Text>
</View>
{!isCurrentUser ? <View style={styles.messageSpacer} /> : undefined }
</View>
);
}
}Message.propTypes = {
color: PropTypes.string.isRequired,
message: PropTypes.shape({
createdAt: PropTypes.string.isRequired,
from: PropTypes.shape({
username: PropTypes.string.isRequired,
}),
text: PropTypes.string.isRequired,
}).isRequired,
isCurrentUser: PropTypes.bool.isRequired,
};export default Message;
We add Messages
to AppNavigator
so it will stack on top of our Main
screen when we navigate to it:
Step 3.6: Add Messages to Navigation
Changed client/src/navigation.js
...import { connect } from 'react-redux';import Groups from './screens/groups.screen';
import Messages from './screens/messages.screen';const styles = StyleSheet.create({
container: {...
const AppNavigator = StackNavigator({
Main: { screen: MainScreenNavigator },
Messages: { screen: Messages },
}, {
mode: 'modal',
});// reducer initialization code
Finally, modify Groups
to handle pressing a Group
. We can use props.navigation.navigate
, which is passed to our Groups
component via React Navigation, to push the Messages
Screen:
Step 3.6: Add Messages to Navigation
Changed client/src/screens/groups.screen.js
...}));class Group extends Component {
constructor(props) {
super(props); this.goToMessages = this.props.goToMessages.bind(this, this.props.group);
} render() {
const { id, name } = this.props.group;
return (
<TouchableHighlight
key={id}
onPress={this.goToMessages}
>
<View style={styles.groupContainer}>
<Text style={styles.groupName}>{`${name}`}</Text>...}Group.propTypes = {
goToMessages: PropTypes.func.isRequired,
group: PropTypes.shape({
id: PropTypes.number,
name: PropTypes.string,... title: 'Chats',
}; constructor(props) {
super(props);
this.goToMessages = this.goToMessages.bind(this);
} keyExtractor = item => item.id.toString(); goToMessages(group) {
const { navigate } = this.props.navigation;
navigate('Messages', { groupId: group.id, title: group.name });
} renderItem = ({ item }) => <Group group={item} goToMessages={this.goToMessages} />; render() {
// render list of groups for user... );
}
}
Groups.propTypes = {
navigation: PropTypes.shape({
navigate: PropTypes.func,
}),
};export default Groups;
Our app should now have simple layouts and routing for showing groups and pressing into a group to show that group’s message thread:
GraphQL Queries with React-Apollo
Time to connect our data!
On our server, we created a user
query which will give our client access to plenty of data for a given User. Let’s use this query in our Groups
component to get the user’s list of groups. It’s good form to keep queries separate of components because queries can often be reused by multiple components. We will create a new folder client/src/graphql
to house all our queries, and create user.query.js
inside this folder:
Step 3.7: Create USER_QUERY
Added client/src/graphql/user.query.js
...import gql from 'graphql-tag';// get the user and all user's groups
export const USER_QUERY = gql`
query user($id: Int) {
user(id: $id) {
id
username
groups {
id
name
}
}
}
`;export default USER_QUERY;
This query should look just like something we would insert into GraphQL Playground. We just use graphql-tag
to parse the query so that our client will make the proper GraphQL request to the server.
Inside groups.screen.js
, we will import USER_QUERY
and connect it to the Groups
component via react-apollo
. react-apollo
exposes a graphql
module which requires a query, and can be passed an Object with optional configuration parameters as its second argument. Using this second argument, we can declare variables that will be passed to the query:
import { graphql, compose } from 'react-apollo';
import { USER_QUERY } from '../graphql/user.query';// graphql() returns a func that can be applied to a React component
// set the id variable for USER_QUERY using the component's existing props
const userQuery = graphql(USER_QUERY, {
options: (ownProps) => ({ variables: { id: ownProps.id }}),
});// Groups props will now have a 'data' paramater with the results from graphql (e.g. this.props.data.user)
export default userQuery(Groups);
When we apply userQuery
to the Groups
component, Groups’ props will now also contain a data
property with a loading parameter and the name of our Query (user
):
Groups.propTypes = {
...
data: {
loading: PropTypes.bool,
user: PropTypes.shape({
id: PropTypes.number.isRequired,
email: PropTypes.string.isRequired,
groups: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
}),
),
}),
}
};
When the Query is loading, props.data.loading
will be true and props.data.user
will be undefined
. When data is returned, props.data.loading
will be false
and props.data.user
will be populated. There is also a neat trick we can do using the props
parameter of options we pass to graphql
:
const userQuery = graphql(USER_QUERY, {
options: (ownProps) => ({ variables: { id: ownProps.id } }),
props: ({ data: { loading, user } }) => ({
loading, user,
}),
});
By using the props
parameter in the graphql
options, we can shape how we want the data to look on our component’s props. Here, we eliminate the data
piece from Groups.props
and directly place loading
and user
onto Groups.props
.
Finally, we’ll modify our Groups component to render a loading screen while we are loading, and a list of Groups once we receive the data:
Step 3.8: Apply USER_QUERY to Groups
Changed client/src/screens/groups.screen.js
...import PropTypes from 'prop-types';
import React, { Component } from 'react';
import {
FlatList,
ActivityIndicator,
StyleSheet,
Text,
TouchableHighlight,
View,
} from 'react-native';
import { graphql } from 'react-apollo';import { USER_QUERY } from '../graphql/user.query';const styles = StyleSheet.create({
container: {
backgroundColor: 'white',
flex: 1,
},
loading: {
justifyContent: 'center',
flex: 1,
},
groupContainer: {
flex: 1,
flexDirection: 'row',... },
});class Group extends Component {
constructor(props) {
super(props);... renderItem = ({ item }) => <Group group={item} goToMessages={this.goToMessages} />; render() {
const { loading, user } = this.props; // render loading placeholder while we fetch messages
if (loading || !user) {
return (
<View style={[styles.loading, styles.container]}>
<ActivityIndicator />
</View>
);
} // render list of groups for user
return (
<View style={styles.container}>
<FlatList
data={user.groups}
keyExtractor={this.keyExtractor}
renderItem={this.renderItem}
/>... navigation: PropTypes.shape({
navigate: PropTypes.func,
}),
loading: PropTypes.bool,
user: PropTypes.shape({
id: PropTypes.number.isRequired,
email: PropTypes.string.isRequired,
groups: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
}),
),
}),
};const userQuery = graphql(USER_QUERY, {
options: () => ({ variables: { id: 1 } }), // fake the user for now
props: ({ data: { loading, user } }) => ({
loading, user,
}),
});export default userQuery(Groups);
By passing in {id: 1}
for our variables, we are pretending to be logged in as the User with id = 1. In Part 7 of these tutorials, we will add authentication to our application so we don’t have to fake it anymore.
With our server running, if we refresh the application, we should see the groups displayed for User id = 1:
A Few Different Ways to Connect GraphQL and React
react-apollo
actually gives us multiple ways to connect data from a GraphQL Query to a React component.
- The most straightforward way is to use the
graphql
module fromreact-apollo
:
import { graphql } from 'react-apollo';...// graqphql(QUERY, queryConfig)(Component)
const componentWithData = graphql(USER_QUERY, {
options: () => ({ variables: { id: 1 } }),
props: ({ data: { loading, user } }) => ({
loading, user,
}),
})(Groups);export default componentWithData;
2. withApollo
gives direct access to your ApolloClient
instance as a prop to your wrapped component. If you’re trying to do something fancy with custom logic like a one-off query, this module let’s you take charge of how your component gets its data:
import { withApollo } from 'react-apollo';
import ApolloClient from 'apollo-client';Groups.propTypes = {
client: React.PropTypes.instanceOf(ApolloClient),
}// GroupsWithApollo now has props.client with ApolloClient
const GroupsWithApollo = withApollo(Groups);
3. My personal favorite method is the react-apollo
compose
module, which makes it easy to elegantly attach multiple queries, mutations, subscriptions, and your Redux store to the component in a single assignment:
import { graphql, compose } from 'react-apollo';
import { connect } from 'react-redux';...const userQuery = graphql(USER_QUERY, {
options: () => ({ variables: { id: 1 } }),
props: ({ data: { loading, user } }) => ({
loading, user,
}),
});
const otherQuery = graphql(OTHER_QUERY, otherConfig);// this is fire!
const componentWithData = compose(
userQuery, // first query
otherQuery, // second query
connect(mapStateToProps, mapDispatchToProps), // redux
)(Groups);export default componentWithData;
Getting Messages
I think this might be a good moment to reflect on the coolness of GraphQL.
In the olden days, to get data for the Screen for our Messages, we might start by consuming a REST API that gets Messages. But later down the line, we might also need to show details about the Group. In order to accomplish this, we would either have to make calls to different endpoints for Group details and Messages associated with the Group, or stuff the Messages into our Group endpoint. Womp.
With GraphQL, we can run a single call for exactly what we need in whatever shape we want. We can think about getting data starting with a node and going down the graph. In this case, we can query for a Group, and within that Group, request the associated Messages in the amount we want, and modify that amount when we need.
This time, let’s try using compose
. Our process will be similar to the one we used before:
- Create a GraphQL Query for getting the Group
- Add configuration params for our Query (like a variable to identify which Group we need)
- Use the react-apollo graphql module to wrap the Messages component, passing in the Query and configuration params. We’ll also use compose just to get a feel for it. Let’s start by creating our query in
client/src/graphql/group.query.js
:
Step 3.9: Create GROUP_QUERY
Added client/src/graphql/group.query.js
...import gql from 'graphql-tag';const GROUP_QUERY = gql`
query group($groupId: Int!) {
group(id: $groupId) {
id
name
users {
id
username
}
messages {
id
from {
id
username
}
createdAt
text
}
}
}
`;export default GROUP_QUERY;
So this Query is pretty cool. Given a groupId
, we can get whatever features of the Group
we need including the Messages
. For now, we are asking for all the Messages
in this Group
. That’s a good starting point, but later we’ll modify this query to return a limited number of Messages
at a time, and append more Messages
as the user scrolls.
Finally, let’s attach our GROUP_QUERY
to the Messages
component:
Step 3.10: Apply GROUP_QUERY to Messages
Changed client/src/screens/messages.screen.js
...import {
ActivityIndicator,
FlatList,
StyleSheet,
View,...import PropTypes from 'prop-types';
import React, { Component } from 'react';
import randomColor from 'randomcolor';
import { graphql, compose } from 'react-apollo';import Message from '../components/message.component';
import GROUP_QUERY from '../graphql/group.query';const styles = StyleSheet.create({
container: {... flex: 1,
flexDirection: 'column',
},
loading: {
justifyContent: 'center',
},
});class Messages extends Component {
static navigationOptions = ({ navigation }) => {... };
}; constructor(props) {
super(props);
const usernameColors = {};
if (props.group && props.group.users) {
props.group.users.forEach((user) => {
usernameColors[user.username] = randomColor();
});
} this.state = {
usernameColors,
}; this.renderItem = this.renderItem.bind(this);
} componentWillReceiveProps(nextProps) {
const usernameColors = {};
// check for new messages
if (nextProps.group) {
if (nextProps.group.users) {
// apply a color to each user
nextProps.group.users.forEach((user) => {
usernameColors[user.username] = this.state.usernameColors[user.username] || randomColor();
});
} this.setState({
usernameColors,
});
}
} keyExtractor = item => item.id.toString(); renderItem = ({ item: message }) => (
<Message
color={this.state.usernameColors[message.from.username]}
isCurrentUser={message.from.id === 1} // for now until we implement auth
message={message}
/>
) render() {
const { loading, group } = this.props; // render loading placeholder while we fetch messages
if (loading && !group) {
return (
<View style={[styles.loading, styles.container]}>
<ActivityIndicator />
</View>
);
} // render list of messages for group
return (
<View style={styles.container}>
<FlatList
data={group.messages.slice().reverse()}
keyExtractor={this.keyExtractor}
renderItem={this.renderItem}
ListEmptyComponent={<View />}... }
}Messages.propTypes = {
group: PropTypes.shape({
messages: PropTypes.array,
users: PropTypes.array,
}),
loading: PropTypes.bool,
};const groupQuery = graphql(GROUP_QUERY, {
options: ownProps => ({
variables: {
groupId: ownProps.navigation.state.params.groupId,
},
}),
props: ({ data: { loading, group } }) => ({
loading, group,
}),
});export default compose(
groupQuery,
)(Messages);
If we fire up the app, we should see our messages:
Looking good!
That’s it for Part 3. In the next part of this series, we will add GraphQL Mutations to our server and client so our users can send messages.
As always, please share your thoughts, questions, struggles, and breakthroughs below!