Chapter 9: Subscriptions

Full Stack React Native

link Subscriptions

By the end of this lesson, developers will be able to:

  • Subscribe to live GraphQL updates
  • Create, Update and Destroy posts

link The Story So Far

So far, we set up a React Native application and an Express server. The app is designed to create posts and subscribe to live updates.

Our goal is to implement an app that will perform Full CRUD operations:

  • Create
  • Read
  • Update
  • Destroy

What are GraphQL Subscriptions?

In addition to fetching data using queries and modifying data using mutations, the GraphQL spec supports a third operation type, called Subscription.

GraphQL subscriptions are a way to push data from the server to the clients that choose to listen to real time messages from the server. Subscriptions are similar to queries in that they specify a set of fields to be delivered to the client, but instead of immediately returning a single answer, a result is sent every time a particular event happens on the server.

A common use case for subscriptions is notifying the client side about particular events, for example the creation of a new object, updated fields and so on.

App Setup

git clone https://github.com/Maelstroms38/reddit-starter.git
cd reddit-starter
yarn
yarn start

Server Setup

git clone https://github.com/Maelstroms38/reddit-api
cd reddit-api
yarn
yarn db:reset
yarn start

Setting Up

Let's start by opening and running both projects in two separate windows.

cd <app-name>
yarn start

cd <server-name>
yarn start

Once you have confirmed both projects are running, we will add a client to the React Native project.

Heads Up! This lesson's code edits will only affect your React Native project.

link GraphQL Queries - Apollo Websockets Client

Builder Book

Source

The setup for this project deviates from previous React Native clients. We will be using a Websocket-ready client, thanks to the following new dependencies:

The most popular transport for GraphQL subscriptions today is subscriptions-transport-ws. This package is maintained by the Apollo community, but can be used with any client or server GraphQL implementation.

Source

Let's look at how to add support for this transport to Apollo Client.

Install the following dependencies we need to get started:

yarn add @apollo/react-hooks apollo-client apollo-cache-inmemory apollo-link apollo-link-http apollo-utilities graphql graphql-tag apollo-link-ws subscriptions-transport-ws

Make the following changes in the top level imports of App.js:

import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { ApolloClient } from 'apollo-client';
import { split } from 'apollo-link';
import { ApolloProvider } from '@apollo/react-hooks';
import { InMemoryCache } from 'apollo-cache-inmemory';
import { HttpLink } from 'apollo-link-http';
import { WebSocketLink } from 'apollo-link-ws';
import { getMainDefinition } from 'apollo-utilities';
import AppNavigator from './AppNavigator';

const HTTP_URL = 'http://';
const HTTPS_URL = 'https://';
const WS_URL = 'ws://';
const WSS_URL = 'wss://';
const DEV_URL = 'localhost:4000/graphql';
const BASE_URL = '<EXAMPLE-GRAPHQL-URL.herokuapp.com/graphql>';

It's time to setup the GraphQL websocket client. The client can determine whether to send data to the ws:// websocket endpoint, or the http:// HTTP endpoint, based on the type of operation.

Let's add the following HttpLink and WebSocketLink inside App.js, and initialize a new ApolloClient.

const httpLink = new HttpLink({
  uri: `${HTTP_URL}${DEV_URL}`
});

// Create a WebSocket link
const wsLink = new WebSocketLink({
  uri: `${WS_URL}${DEV_URL}`,
  options: {
    lazy: true,
    reconnect: true
  }
});

// using the ability to split links, you can send data to each link
// depending on what kind of operation is being sent
const link = split(
  // split based on operation type
  ({ query }) => {
    const definition = getMainDefinition(query);
    return (
      definition.kind === 'OperationDefinition' &&
      definition.operation === 'subscription'
    );
  },
  wsLink,
  httpLink
);

const client = new ApolloClient({
  link,
  cache: new InMemoryCache()
});

Finally, wrap the AppNavigator inside the ApolloProvider:

export default function App() {
  return (
    <ApolloProvider client={client}>
      <View style={styles.container}>
        <AppNavigator />
      </View>
    </ApolloProvider>
  );
}

We now have a functioning GraphQL websocket client. Let's utilize this new functionality with subscriptions!

link Subscribe to More

The Posts screen displays a static list. Let's modify it to fetch and subscribe to post updates.

With GraphQL subscriptions your client will be alerted on push from the server and you should choose the pattern that fits your application the most:

  • Use it as a notification and run any logic you want when it fires, for example alerting the user or refetching data
  • Use the data sent along with the notification and merge it directly into the store (existing queries are automatically notified)

With the subscribeToMore hook, you can easily do the latter.

Make the following changes to Posts.js top level imports:

-import React from 'react';
+import React, { useEffect } from 'react';
 import { StyleSheet, Text, View, FlatList, TouchableOpacity, ActivityIndicator } from 'react-native';
 import { Ionicons } from '@expo/vector-icons';
+import { useQuery } from '@apollo/react-hooks';
+import gql from 'graphql-tag';
 import Post from '../components/Post';

-const data = {
-  posts: [
-    {
-      "id": 31,
-      "title": "Hello World",
-      "link": "https://www.unsplash.com",
-      "imageUrl": "https://images.unsplash.com/photo-1484100356142-db6ab6244067?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1562&q=80"
-    }
- ]
-}

Be sure to remove static data.

Add the following queries and subscriptions below the imports:

// Queries
const POSTS_QUERY = gql`
  query {
    posts {
      id
      title
      link
      imageUrl
    }
  }
`;

// Subscriptions
const POSTS_SUBSCRIPTION = gql`
  subscription {
    postAdded {
      id
      title
      link
      imageUrl
    }
  }
`;

We are about to change the way we fetch and subscribe to post updates. The source code will enable a subscribeToMore hook to track changes each time a user adds a new post.

export default function Posts(props) {
  const { navigation } = props;
  const { subscribeToMore, loading, error, data } = useQuery(POSTS_QUERY, {
    variables: {}
  });
  useEffect(() => {
    subscribeToMore({
      document: POSTS_SUBSCRIPTION,
      variables: {},
      updateQuery: (prev, { subscriptionData }) => {
        if (!subscriptionData.data) return prev;
        const newPostItem = subscriptionData.data.postAdded;
        return Object.assign({}, prev, {
          posts: [newPostItem, ...prev.posts]
        });
      }
    });
  }, []);

  if (loading) {
    return (<View style={{flex: 1, justifyContent: 'center'}}>
                <ActivityIndicator size="large" color="#161616" />
              </View>)
  }

  if (error) {
    return <Text>{error.message}</Text>
  }

  const { posts } = data;
  return (
    <View style={styles.container}>
      <FlatList
        data={posts}
        keyExtractor={(item, index) => {
          return `${index}`;
        }}
        renderItem={(post, index) => {
          const { item } = post;
          return (
            <Post post={item} navigation={props.navigation} />
          );
        }}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    justifyContent: 'space-around'
  },
});

The subscribeToMore hook has powerful implications:

  • The app will always stay up to date, with live rendering based on subscriptions.
  • Anyone using the app collaborates in real time with other users.

In summary, this app displays a live feed with minimal code. Let's polish it off with a new navigation action, to add our first post.

By now you should see a new "+" icon on the navigation bar's top right corner. We have yet to define a PostForm screen, so let's set it up!

link PostForm

PostForm.js will allow us to create posts. Let's begin with the top level imports.

Inside a new ./screens/PostForm.js file, add the following:

import React, { useState } from 'react';
import {
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
  Dimensions,
  TextInput,
  Alert
} from 'react-native';
import RoundedButton from '../components/RoundedButton';
import { Ionicons } from '@expo/vector-icons';
import { Linking } from 'expo';
import { useMutation } from '@apollo/react-hooks';
import gql from 'graphql-tag';

const {width} = Dimensions.get('window');

Below the top level imports, we add the addPost mutation:

// Mutations
const ADD_POST = gql`
  mutation AddPost($title:String!,$link:String!,$imageUrl:String!) {
    addPost(title: $title, link:$link, imageUrl: $imageUrl)
  }
`;

The post form is designed to render three input fields:

  • Title
  • Link
  • Image URL.

We can implement these input forms with hooks to manage state changes. Let's do so:

export default function PostForm({ route, navigation }) {
  const [addPost] = useMutation(ADD_POST); 
  const [title, setTitle] = useState('');
  const [link, setLink] = useState('');
  const [imageUrl, setImageUrl] = useState('');
  return (
    <View style={styles.container}>
      <View style={styles.inputContainer}>
        <View>
          <Text>Title</Text>
          <TextInput 
              onChangeText={text => setTitle(text)}
              value={title}
              placeholder="Title"
              autoCorrect={false}
              autoCapitalize="none"
              style={styles.input}
              autoFocus
            />
          </View>
        <View>
          <Text>Link</Text>
          <TextInput 
            onChangeText={text => setLink(text)}
            value={link}
            placeholder="Link"
            autoCorrect={false}
            autoCapitalize="none"
            style={styles.input}
          />
        </View>
        <View>
          <Text>Image URL</Text>
          <TextInput 
            onChangeText={text => setImageUrl(text)}
            value={imageUrl}
            placeholder="Image URL"
            autoCorrect={false}
            autoCapitalize="none"
            style={styles.input}
          />
        </View>
      </View>

      <RoundedButton
        text="Create Post"
        textColor="#fff"
        backgroundColor="rgba(75, 148, 214, 1)"
        onPress={() => {
          // TextInput validation
          let nullValues = [];
          if (!title) {
            nullValues.push("Title");
          }
          if (!link) {
            nullValues.push("Link");
          }
          if (!imageUrl) {
            nullValues.push("Image URL");
          }
          if (nullValues.length) {
            Alert.alert(`Please fill in ${nullValues.join(', ')}`);
          } else {
            addPost({ variables: { title, link, imageUrl } })
            .then(() => {
              navigation.goBack();
            })
            .catch(err => console.log(err));
          }
        }}
        icon={<Ionicons name="md-checkmark-circle" size={20} color={"#fff"} style={styles.saveIcon} />}
      />
    </View>
  );
}

Note the validation for each input field is handled by the onSubmit method. If any field is empty, we prompt the user to fill it out.

Finishing off PostForm, we include a navigation title and styles:

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'flex-start',
    alignItems: 'center',
    paddingHorizontal: 15,
  },
  saveIcon: {
    position: 'relative',
    left: 20,
    zIndex: 8
  },
  inputContainer: {
    flex: 0.40,
    justifyContent: 'space-around',
  },
  input: {
    width: width - 40,
    height: 40,
    borderBottomColor: '#FFF',
    borderBottomWidth: 1,
  },
  image: {
    width: width,
    height: width,
    resizeMode: 'cover',
  },
});

PostForm Navigation

In order to navigate to the PostForm screen, we will need to add it to AppNavigator.js. Make the following changes inside AppNavigator.js:

import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { Ionicons } from '@expo/vector-icons';
import Posts from '../screens/Posts';
import Detail from '../screens/PostDetail';
import PostForm from '../screens/PostForm';

const Stack = createStackNavigator();

export default function AppNavigator() {
  return (
    <Stack.Navigator>
      <Stack.Screen 
          name="Posts" 
          component={Posts} 
          options={({ navigation, route }) => ({
            headerRight: props => (
            <Ionicons 
              onPress={() => navigation.navigate('PostForm')} 
              name="md-add" 
              size={25} 
              color={"#161616"} 
              style={{
                position: 'relative',
                right: 20,
                zIndex: 8
              }} />
            ),
        })} />
      <Stack.Screen name="Detail" component={Detail} />
      <Stack.Screen name="PostForm" component={PostForm} options={{
        title: "Create Post"
      }} />
    </Stack.Navigator>
  )
};

Reload the app and try to press the "+" button. Fill out this form to add new posts to the server!

link EditForm

Now that we can add posts, let's allow users to edit posts. If you have been following along our goal is to create Full CRUD functionality. So far we have:

  • Create
  • Read
  • Update
  • Destroy

So we are halfway there! Let's continue to make an EditForm.

Create a new file called ./screens/EditForm.js. Add these imports at the top level:

import React, { useState } from 'react';
import {
  StyleSheet,
  Text,
  TouchableOpacity,
  View,
  Dimensions,
  TextInput,
  Alert
} from 'react-native';
import RoundedButton from '../components/RoundedButton';
import { Ionicons } from '@expo/vector-icons';
import { Linking } from 'expo';
import { useMutation } from '@apollo/react-hooks';
import gql from 'graphql-tag';

const {width} = Dimensions.get('window');

We will create a new mutation called editPost that will allow users to edit an existing post.

// Mutations
const EDIT_POST = gql`
  mutation EditPost($id: ID!, $title: String!, $link: String!, $imageUrl: String!) {
    editPost(id: $id, title: $title, link: $link, imageUrl: $imageUrl) {
      id
      title
      link
      imageUrl
    }
  }
`;

Now for the form itself. We can use hooks again to manage our form's internal state. By managing the form state we consider this a "controlled component".

export default function EditForm({ route, navigation }) {
  const { params } = route;
  const { post } = params;
  const { id } = post;
  const [editPost] = useMutation(EDIT_POST); 
  const [title, setTitle] = useState(post.title);
  const [link, setLink] = useState(post.link);
  const [imageUrl, setImageUrl] = useState(post.imageUrl);
  return (
    <View style={styles.container}>
      <View style={styles.inputContainer}>
        <View>
          <Text>Title</Text>
          <TextInput 
              onChangeText={text => setTitle(text)}
              value={title}
              placeholder="Title"
              autoCorrect={false}
              autoCapitalize="none"
              style={styles.input}
              autoFocus
            />
          </View>
        <View>
          <Text>Link</Text>
          <TextInput 
            onChangeText={text => setLink(text)}
            value={link}
            placeholder="Link"
            autoCorrect={false}
            autoCapitalize="none"
            style={styles.input}
          />
        </View>
        <View>
          <Text>Image URL</Text>
          <TextInput 
            onChangeText={text => setImageUrl(text)}
            value={imageUrl}
            placeholder="Image URL"
            autoCorrect={false}
            autoCapitalize="none"
            style={styles.input}
          />
        </View>
      </View>

      <RoundedButton
        text="Edit Post"
        textColor="#fff"
        backgroundColor="rgba(75, 148, 214, 1)"
        onPress={() => {
          // TextInput validation
          let nullValues = [];
          if (!title) {
            nullValues.push("Title");
          }
          if (!link) {
            nullValues.push("Link");
          }
          if (!imageUrl) {
            nullValues.push("Image URL");
          }
          if (nullValues.length) {
            Alert.alert(`Please fill in ${nullValues.join(', ')}`);
          } else {
            editPost({ variables: { id, title, link, imageUrl } })
            .then(() => {
              navigation.navigate('Detail', {post: { id, title, link, imageUrl }});
            })
            .catch(err => console.log(err));
          }
        }}
        icon={<Ionicons name="md-checkmark-circle" size={20} color={"#fff"} style={styles.saveIcon} />}
      />
    </View>
  );
}

At the bottom of the EditForm, add the following navigation title and styles:

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'flex-start',
    alignItems: 'center',
    paddingHorizontal: 15,
  },
  saveIcon: {
    position: 'relative',
    left: 20,
    zIndex: 8
  },
  inputContainer: {
    flex: 0.40,
    justifyContent: 'space-around',
  },
  input: {
    width: width - 40,
    height: 40,
    borderBottomColor: '#FFF',
    borderBottomWidth: 1,
  },
});

EditForm Navigation

In order to navigate to the EditForm screen, we will need to add it to AppNavigator.js. Make the following changes inside AppNavigator.js:

import React from 'react';
import { createStackNavigator } from '@react-navigation/stack';
import { Ionicons } from '@expo/vector-icons';
import Posts from '../screens/Posts';
import Detail from '../screens/PostDetail';
import PostForm from '../screens/PostForm';
import EditForm from '../screens/EditForm';

const Stack = createStackNavigator();

export default function AppNavigator() {
  return (
    <Stack.Navigator>
      <Stack.Screen 
          name="Posts" 
          component={Posts} 
          options={({ navigation, route }) => ({
            headerRight: props => (
            <Ionicons 
              onPress={() => navigation.navigate('PostForm')} 
              name="md-add" 
              size={25} 
              color={"#161616"} 
              style={{
                position: 'relative',
                right: 20,
                zIndex: 8
              }} />
            ),
        })} />
      <Stack.Screen name="Detail" component={Detail} />
      <Stack.Screen name="PostForm" component={PostForm} options={{
        title: "Create Post"
      }} />
      <Stack.Screen name="EditForm" component={EditForm} options={{
        title: "Edit Post"
      }} />
    </Stack.Navigator>
  )
};

With the EditForm ready, we can add a button to navigate to this new screen. We will add an "Edit" button to ./screens/PostDetail.js, below the "Link" button.

export default function PostDetail({ route, navigation }) {
  const { params } = route;
  const { post } = params;
  const {
    title,
    link,
    imageUrl,
  } = post;
  return (
    <View style={styles.container}>
      <Image style={styles.image} source={{uri: imageUrl}} />
      <Text style={styles.text}>{title}</Text>
      <RoundedButton
        text={`${link}`}
        textColor="#fff"
        backgroundColor="rgba(75, 148, 214, 1)"
        onPress={() => {
          Linking.openURL(link)
          .catch(err => console.log(err));
        }}
        icon={<Ionicons name="md-link" size={20} color={"#fff"} style={styles.saveIcon} />}
      />
+      <RoundedButton
+        text="Edit"
+        textColor="#fff"
+        backgroundColor="#a9a9a9"
+        onPress={() => {
+          navigation.navigate('EditForm', {post});
+        }}
+        icon={<Ionicons name="md-options" size={20} color={"#fff"} style={styles.saveIcon} />}
+      />
     </View>

Reload the app and select a post, then tap the "Edit" button to see your new EditForm. Submit edits to see your posts list re-render the updated live data.

link Delete Post

It is safe to say we have built out most of the Full CRUD functionality:

  • Create
  • Read
  • Update
  • Destroy

Let's complete the Full CRUD with a "Delete" button. Add the following deletePost mutation to ./screens/PostDetail.js:

import React from 'react';
import {
 StyleSheet,
 Text,
 Image,
 View,
 Dimensions
} from 'react-native';
import RoundedButton from '../components/RoundedButton';
+import { useMutation } from '@apollo/react-hooks';
import { Ionicons } from '@expo/vector-icons';
import { Linking } from 'expo';
+import gql from 'graphql-tag';

const {width} = Dimensions.get('window');

+// Mutations
+const DELETE_POST = gql`
+  mutation DeletePost($id: ID!) {
+    deletePost(id: $id)
+  }
+`;
+

Now we can reference the mutation with a "Delete" button.
Add the "Delete" button to PostDetail.js:

export default function PostDetail({ route, navigation }) {
+ const [deletePost] = useMutation(DELETE_POST); 
  const { params } = route;
  const { post } = params;
  const {
    title,
    link,
    imageUrl,
  } = post;
  return (
    <View style={styles.container}>
      <Image style={styles.image} source={{uri: imageUrl}} />
      <Text style={styles.text}>{title}</Text>
      <RoundedButton
        text={`${link}`}
        textColor="#fff"
        backgroundColor="rgba(75, 148, 214, 1)"
        onPress={() => {
          Linking.openURL(link)
          .catch(err => console.log(err));
        }}
        icon={<Ionicons name="md-link" size={20} color={"#fff"} style={styles.saveIcon} />}
      />
      <RoundedButton
        text="Edit"
        textColor="#fff"
        backgroundColor="#a9a9a9"
        onPress={() => {
          navigation.navigate('EditForm', {post});
        }}
        icon={<Ionicons name="md-options" size={20} color={"#fff"} style={styles.saveIcon} />}
      />
+      <RoundedButton
+        text="Delete"
+        textColor="#fff"
+        backgroundColor="#FA8072"
+        onPress={() => {
+          deletePost({ variables: {id: post.id}})
+          .then(() => navigation.goBack())
+          .catch(err => console.log(err));
+        }}
+        icon={<Ionicons name="md-trash" size={20} color={"#fff"} style={styles.saveIcon} />}
+      />
    </View>
  );
}

Reload the app and try to delete any post. If it works, congratulations!

WELL DONE!

link Bonus Challenge

LinkForm

Combine PostForm and EditForm into one form, called LinkForm. The form should be able to create or edit an existing post based on navigation params.

Wrapping Up

Today we made a React Native app with Full CRUD functionality:

  • Create
  • Read
  • Update
  • Destroy

Up next, we will dive into associations and authentication.

format_list_bulleted
help_outline