Next up is subscriptions. This is a fun feature of GraphQL and AppSync in general. The ability to leverage real-time data can really bring some nice UX to your apps. I tend to use it sparingly, but it's super helpful for small lists and instant feedback.
Our previous posts have built up a fairly nice set of abstractions that we can use here as well. Because of this, I'm going to paste the result at the start this time and break it down into pieces. Grab a cup of coffee and we'll get to it.
The Code
Here's our finished custom hook:
type ConfigType<VariableType extends {}> = {
query: string;
key: string;
variables?: VariableType;
};
export const useSubscriptionByItself = <
ItemType extends { id: string },
VariablesType extends {} = {}
>({
config,
itemData,
}: {
config?: ConfigType<VariablesType>;
itemData?: ItemType;
} = {}) => {
const [item, update] = React.useState<ItemType | undefined>(itemData);
React.useEffect(() => {
let unsubscribe;
if (config) {
const { query, key, variables } = config;
const subscription = API.graphql(graphqlOperation(query, variables));
if (subscription instanceof Observable) {
const sub = subscription.subscribe({
next: payload => {
try {
const {
value: {
data: { [key]: item },
},
}: {
value: { data: { [key: string]: ItemType } };
} = payload;
update(item);
} catch (error) {
console.error(
`${error.message} - Check the key property: the current value is ${key}`
);
}
},
});
unsubscribe = () => {
sub.unsubscribe();
};
}
}
return unsubscribe;
}, [JSON.stringify(config)]);
return [item];
};
There's a lot here, but our use-case is simple. Our subscription is going to handle an item. This could be something as simple as subscribing to new blog posts that are created, for example:
const [item] = useSubscription<postFragment>({
config: {
key: 'onCreatePost',
query: onCreatePost,
},
});
We could also pass some variables to subscribe to a comment when it is updated:
const [comment] = useSubscriptionByItself<
commentFragment,
onUpdateCommentSubscriptionVariables
>({
itemData: comment,
config: {
key: 'onUpdateComment',
query: onUpdateComment,
variables: {
id,
},
},
});
The point is that we are able to take the boilerplate of
const subscription = API.graphql(graphqlOperation(query, variables));
and extract it away into something that can be re-used, as well as leaning into the convention of how AWS Amplify returns data to handle everything in a strongly-typed way.
Let's start at the top and see what's going on.
Typing the Configuration
type ConfigType<VariableType extends {}> = {
query: string;
key: string;
variables?: VariableType;
};
export const useSubscription = <
ItemType extends { id: string },
VariablesType extends {} = {}
>({
config,
itemData,
}: {
config?: ConfigType<VariablesType>;
itemData?: ItemType;
} = {}) => {
Let's take a look at the type parameters (the things in between the angle brackets). This takes some explaining because I start out assuming a convention. The ItemType
represents the object that we're going to be returning and operating on in our hook. The extends { id: string }
means that whatever object we pass in, it must have an id of type string
as a property. This is useful, as we want a unique identifier for our object. The itemData
used in case we want to initialize our state.
Note that I'm leveraging fragments to provide a single typed object that we can work with. Once created, the Amplify codegen
tool will create types for your fragments that you can then use as we are in this example. You can learn more about fragments and how to use them with GraphQL here.
The second VariableType
is going to be an object that represents any variables that we will be passing to our subscription graphqlOperation
. This is used further down in the type declaration to the ConfigType
. This represents the configuration that holds the subscription query
, variables
and key
that we will use to establish our subscription. We'll come back to the key
a bit later.
The State
const [item, update] = React.useState<ItemType | undefined>(itemData);
This is pretty straightforward. We use the ItemType
parameter we passed in to type the useState
function. This is possibly undefined, so we note that as well. If we passed in initial itemData
, we use this as well to establish the state that will keep track of the subscription we're working with.
The Effect
Here's the real meat of it.
React.useEffect(() => {
let unsubscribe;
if (config) {
const { query, key, variables } = config;
const subscription = API.graphql(graphqlOperation(query, variables));
if (subscription instanceof Observable) {
const sub = subscription.subscribe({
next: payload => {
try {
const {
value: {
data: { [key]: item },
},
}: {
value: { data: { [key: string]: ItemType } };
} = payload;
update(item);
} catch (error) {
console.error(
`${error.message} - Check the key property: the current value is ${key}`
);
}
},
});
unsubscribe = () => {
sub.unsubscribe();
};
}
}
return unsubscribe;
}, [JSON.stringify(config)]);
First things first, we're going to be establishing a subscription, so for an effect, we need to clean it up when we're done. We declare a variable that will hold the function we want to run when returning from the effect.
Next, we will check if the config exists, as it is optional. We destructure the components and will use them to construct our subscription. The next lines are important:
const subscription = API.graphql(graphqlOperation(query, variables));
if (subscription instanceof Observable) {
...
The API.graphql
call actually returns Observable | Promise<>
- what this means is that the result will be one or the other. To get the autocomplete help that we expect (and stop TypeScript from yelling at us) we need to do what is called "type narrowing" using a type guard. We do this by using the instanceof
keyword to check if the type is an Observable
. I've added the @types/zen-observable
package (yarn add -D @types/zen-observable
) to provide the type.
The Subscription
const sub = subscription.subscribe({
next: payload => {
try {
const {
value: {
data: { [key]: item },
},
}: {
value: { data: { [key: string]: ItemType } };
} = payload;
update(item);
} catch (error) {
console.error(
`${error.message} - Check the key property: the current value is ${key}`
);
}
},
});
unsubscribe = () => {
sub.unsubscribe();
};
We have our subscription that's returned from our graphql call, so now we need to subscribe to it. This is using what is called an observable. Last time I checked, Amplify is using the zen-observable library for the subscription implementation. Observables operate with values by returning them as streams, so you can listen for updates to the stream by supplying callbacks - in this case, next
. Our next
callback takes a payload
(this will be the value of the next event in the stream) and we then do some destructuring on this value to get the underlying data we want. Amplify follows a convention for returning data in the subscriptions, so we can use this to make sure our destructuring is correct.
const {
value: {
data: { [key]: item },
},
}: {
value: { data: { [key: string]: ItemType } };
} = payload;
We use the key
we talked about earlier, as well as the ItemType
type we passed in, to create a type and properly destructure from the nested object (in the form of value.data[key]
). Once this data is handled, we use the update
method from our useState
hook to persist our state, and if anything goes wrong we log out the error.
After the callback, we assign a small arrow function to our unsubscribe
variable that will do the work of unsubscribing from our subscription if the component the hook is used in is unmounted.
[JSON.stringify(config)];
Our useEffect
hook takes in one dependency (an object) so we'll just stringify
it to make sure that if it's changed in any way, our hook will run again and we can re-establish the appropriate subscription.
The last line merely returns the data kept in state, so we can use it from the caller.
return [item];
The Wrap
This is, at its core, just a wrapper over the existing Amplify tools. But for TypeScript projects, it gives you the help you can use to make sure your app is doing what you expect. The nice by-product, in my opinion, is that the API surface is more complete while abstracting away the common bits. It's generally a good practice to extract these things away and avoid having useEffect
directly in your components. This is just one little step in that direction.
If anyone has any feedback on this approach, please leave it in the comments. I use this often, only in TypeScript projects, and I hope it can help someone. You can also ping me on twitter @mwarger .
...
But wait, what if you need to subscribe to many events? That's next up - follow me to be notified when it's published!
Top comments (4)
These article series are very impressive for me.
I used your code, but I couldn't connect appsync on my version.
Amplify uses
zen-observable-ts
at '3.0.8', then its subscription instance is not match Observable on@types/zen-observable
Found a workaround:
Hi Mat! just for reference as I'm new to Amplify... do you have any project on production using it?? Thanks for the amazing information.
Appreciate the kind words Pablo. I did have a project using Amplify in production when I wrote the articles and for a bit after. These hooks were what I wrote to solver some headaches regarding duplicate code and making things a bit easier for myself. Hope you find it useful!