1
0
Fork 0
mirror of https://github.com/terribleplan/next.js.git synced 2024-01-19 02:48:18 +00:00

[refactor] with-apollo-and-redux: 2.0.0 (#3484)

* [refactor] with-apollo-and-redux: 2.0.0

- This ports over `with-apollo` (w/ recent `withRouter` fix and addition
for Post) along with implementing `apollo-cache-redux` #3463
- The `redux` side of things is lacking (it is the *same* as the
original example)
- Created a `routes.js` for use on Server and Client Side (to expand the
PostList functionality)
- SSR is maintained
- Redid the "PostVote" a bit... sorry. 😬️

Possible todo(s):
- Add in API and Clock Examples from `with-redux` to show Apollo and
Redux working together a bit more
- redux-saga (I personally use this, may be too opinionated for the base
example though)

Packages updated:
- apollo-cache-redux
- apollo-client-preset
- graphql
- graphql-anywhere
- graphql-tag
- isomorphic-unfetch
- next-routes
- prop-types
- react
- react-apollo
- react-dom
- redux

* [refactor] fix linting issues

When I run `yarn lint` explicitly these were caught, but not doing a
build proper. Apologies on that!

* [chore] 📦️ package.json: like other examples

* [refactor] +apollo-cache-inmemory, -apollo-cache-redux

Separation of Apollo and Redux. 😄️
We could stand to use a few actual examples of Redux, though this is a
good starting block.
Some other code cleanup as well.
This commit is contained in:
Jerome Fitzgerald 2017-12-27 13:57:57 -05:00 committed by Tim Neutkens
parent e4acd7db59
commit 46b57a6eff
22 changed files with 398 additions and 977 deletions

View file

@ -35,9 +35,9 @@ now
```
## The idea behind the example
By default, Apollo Client creates its own internal Redux store to manage queries and their results. If you are already using Redux for the rest of your app, [you can have the client integrate with your existing store instead](http://dev.apollodata.com/react/redux.html), which is what this example does. This example is identical to the [`with-apollo`](https://github.com/zeit/next.js/tree/master/examples/with-apollo) with the exception of this Redux store integration.
In 2.0.0, Apollo Client severs out-of-the-box support for redux in favor of Apollo's client side state management. This example aims to be an amalgamation of the [`with-apollo`](https://github.com/zeit/next.js/tree/master/examples/with-apollo) and [`with-redux`](https://github.com/zeit/next.js/tree/master/examples/with-redux) examples.
Note that you can acesss the redux store like you normally would using `react-redux`'s `connect` as per [here](http://dev.apollodata.com/react/redux.html#using-connect). Here's a quick example:
Note that you can access the redux store like you normally would using `react-redux`'s `connect`. Here's a quick example:
```js
const mapStateToProps = state => ({
@ -47,5 +47,4 @@ const mapStateToProps = state => ({
export default withData(connect(mapStateToProps, null)(Index));
```
`connect` must go inside `withData` otherwise `connect` will not be able to find the store.
`connect` must go inside `withData` otherwise `connect` will not be able to find the store.

View file

@ -1,19 +1,17 @@
import Link from 'next/link'
import { withRouter } from 'next/router'
export default ({ pathname }) => (
const Header = ({ router: { pathname } }) => (
<header>
<Link prefetch href='/'>
<a className={pathname === '/' && 'is-active'}>Home</a>
<a className={pathname === '/' ? 'is-active' : ''}>Home</a>
</Link>
<Link prefetch href='/about'>
<a className={pathname === '/about' && 'is-active'}>About</a>
<a className={pathname === '/about' ? 'is-active' : ''}>About</a>
</Link>
<Link prefetch href='/blog'>
<a className={pathname === '/blog' && 'is-active'}>Blog</a>
<a className={pathname === '/blog' ? 'is-active' : ''}>Blog</a>
</Link>
<style jsx>{`
header {
margin-bottom: 25px;
@ -29,3 +27,5 @@ export default ({ pathname }) => (
`}</style>
</header>
)
export default withRouter(Header)

View file

@ -1,40 +1,63 @@
import React from 'react'
import { gql, graphql } from 'react-apollo'
import PostUpvoter from './PostUpvoter'
import { withRouter } from 'next/router'
import { graphql } from 'react-apollo'
import gql from 'graphql-tag'
function Post ({ id, data: { loading, error, Post } }) {
return (
<section>
<div key={Post.id}>
<h1>{Post.title}</h1>
<p>ID: {Post.id}<br />URL: {Post.url}</p>
<PostUpvoter id={Post.id} votes={Post.votes} />
</div>
</section>
)
import ErrorMessage from './ErrorMessage'
import PostVoteUp from './PostVoteUp'
import PostVoteDown from './PostVoteDown'
import PostVoteCount from './PostVoteCount'
function Post ({ id, data: { error, Post } }) {
if (error) return <ErrorMessage message='Error loading blog post.' />
if (Post) {
return (
<section>
<div key={Post.id}>
<h1>{Post.title}</h1>
<p>ID: {Post.id}<br />URL: {Post.url}</p>
<span>
<PostVoteUp id={Post.id} votes={Post.votes} />
<PostVoteCount votes={Post.votes} />
<PostVoteDown id={Post.id} votes={Post.votes} />
</span>
</div>
<style jsx>{`
span {
display: flex;
font-size: 14px;
margin-right: 5px;
}
`}</style>
</section>
)
}
return <div>Loading</div>
}
const post = gql`
query post($id: ID!) {
Post(id: $id) {
id
title
votes
url
createdAt
}
query post($id: ID!) {
Post(id: $id) {
id
title
votes
url
createdAt
}
}
`
// The `graphql` wrapper executes a GraphQL query and makes the results
// available on the `data` prop of the wrapped component (PostList)
// Tip: ownProps is parent component's props
export default graphql(post, {
options: (ownProps) => {
return {
variables: {
id: ownProps.id
}
// available on the `data` prop of the wrapped component (Post)
const ComponentWithMutation = graphql(post, {
options: ({ router: { query } }) => ({
variables: {
id: query.id
}
}
}),
props: ({ data }) => ({
data
})
})(Post)
export default withRouter(ComponentWithMutation)

View file

@ -1,28 +1,56 @@
import { gql, graphql } from 'react-apollo'
import { graphql } from 'react-apollo'
import gql from 'graphql-tag'
import { Router } from '../routes'
import ErrorMessage from './ErrorMessage'
import PostUpvoter from './PostUpvoter'
import Link from 'next/link'
import PostVoteUp from './PostVoteUp'
import PostVoteDown from './PostVoteDown'
import PostVoteCount from './PostVoteCount'
const POSTS_PER_PAGE = 10
function PostList ({ data: { loading, error, allPosts, _allPostsMeta }, loadMorePosts }) {
function handleClick (event, id) {
event.preventDefault()
// With route name and params
// Router.pushRoute('blog/entry', { id: id })
// With route URL
Router.pushRoute(`/blog/${id}`)
}
function PostList ({
data: { loading, error, allPosts, _allPostsMeta },
loadMorePosts
}) {
if (error) return <ErrorMessage message='Error loading posts.' />
if (allPosts && allPosts.length) {
const areMorePosts = allPosts.length < _allPostsMeta.count
return (
<section>
<ul>
{allPosts.map((post, index) =>
{allPosts.map((post, index) => (
<li key={post.id}>
<div>
<span>{index + 1}. </span>
<Link href={{ pathname: '/blog/' + post.id }}><a>{post.title}</a></Link>
<PostUpvoter id={post.id} votes={post.votes} />
<a
href={`/blog/${post.id}`}
onClick={(event) => handleClick(event, post.id)}
>
{post.title}
</a>
<PostVoteUp id={post.id} votes={post.votes} />
<PostVoteCount votes={post.votes} />
<PostVoteDown id={post.id} votes={post.votes} />
</div>
</li>
)}
))}
</ul>
{areMorePosts ? <button onClick={() => loadMorePosts()}> {loading ? 'Loading...' : 'Show More'} </button> : ''}
{areMorePosts ? (
<button onClick={() => loadMorePosts()}>
{' '}
{loading ? 'Loading...' : 'Show More'}{' '}
</button>
) : (
''
)}
<style jsx>{`
section {
padding-bottom: 20px;
@ -55,7 +83,7 @@ function PostList ({ data: { loading, error, allPosts, _allPostsMeta }, loadMore
border-style: solid;
border-width: 6px 4px 0 4px;
border-color: #ffffff transparent transparent transparent;
content: "";
content: '';
height: 0;
margin-right: 5px;
width: 0;
@ -67,7 +95,7 @@ function PostList ({ data: { loading, error, allPosts, _allPostsMeta }, loadMore
return <div>Loading</div>
}
const allPosts = gql`
export const allPosts = gql`
query allPosts($first: Int!, $skip: Int!) {
allPosts(orderBy: createdAt_DESC, first: $first, skip: $skip) {
id
@ -75,21 +103,23 @@ const allPosts = gql`
votes
url
createdAt
},
}
_allPostsMeta {
count
}
}
`
export const allPostsQueryVars = {
skip: 0,
first: POSTS_PER_PAGE
}
// The `graphql` wrapper executes a GraphQL query and makes the results
// available on the `data` prop of the wrapped component (PostList)
export default graphql(allPosts, {
options: {
variables: {
skip: 0,
first: POSTS_PER_PAGE
}
variables: allPostsQueryVars
},
props: ({ data }) => ({
data,

View file

@ -1,56 +0,0 @@
import React from 'react'
import { gql, graphql } from 'react-apollo'
function PostUpvoter ({ upvote, votes, id }) {
return (
<button onClick={() => upvote(id, votes + 1)}>
{votes}
<style jsx>{`
button {
background-color: transparent;
border: 1px solid #e4e4e4;
color: #000;
}
button:active {
background-color: transparent;
}
button:before {
align-self: center;
border-color: transparent transparent #000000 transparent;
border-style: solid;
border-width: 0 4px 6px 4px;
content: "";
height: 0;
margin-right: 5px;
width: 0;
}
`}</style>
</button>
)
}
const upvotePost = gql`
mutation updatePost($id: ID!, $votes: Int) {
updatePost(id: $id, votes: $votes) {
id
__typename
votes
}
}
`
export default graphql(upvotePost, {
props: ({ ownProps, mutate }) => ({
upvote: (id, votes) => mutate({
variables: { id, votes },
optimisticResponse: {
__typename: 'Mutation',
updatePost: {
__typename: 'Post',
id: ownProps.id,
votes: ownProps.votes + 1
}
}
})
})
})(PostUpvoter)

View file

@ -0,0 +1,30 @@
export default ({onClickHandler, className}) => (
<button className={className} onClick={() => onClickHandler()}>
<style jsx>{`
button {
background-color: transparent;
border: 1px solid #e4e4e4;
color: #000;
}
button:active {
background-color: transparent;
}
button:before {
align-self: center;
border-color: transparent transparent #000000 transparent;
border-style: solid;
border-width: 0 4px 6px 4px;
content: '';
height: 0;
margin-right: 0px;
width: 0;
}
.downvote {
transform: rotate(180deg);
}
.upvote {
transform: rotate(0deg);
}
`}</style>
</button>
)

View file

@ -0,0 +1,12 @@
export default ({votes}) => (
<span>
{votes}
<style jsx>{`
span {
padding: 0.5em 0.5em;
font-size: 14px;
color: inherit;
}
`}</style>
</span>
)

View file

@ -0,0 +1,41 @@
import React from 'react'
import { graphql } from 'react-apollo'
import gql from 'graphql-tag'
import PostVoteButton from './PostVoteButton'
function PostVoteDown ({ downvote, votes, id }) {
return (
<PostVoteButton
className='downvote'
onClickHandler={() => downvote(id, votes - 1)}
/>
)
}
const downvotePost = gql`
mutation updatePost($id: ID!, $votes: Int) {
updatePost(id: $id, votes: $votes) {
id
__typename
votes
}
}
`
export default graphql(downvotePost, {
props: ({ ownProps, mutate }) => ({
downvote: (id, votes) =>
mutate({
variables: { id, votes },
optimisticResponse: {
__typename: 'Mutation',
updatePost: {
__typename: 'Post',
id: ownProps.id,
votes: ownProps.votes - 1
}
}
})
})
})(PostVoteDown)

View file

@ -0,0 +1,41 @@
import React from 'react'
import { graphql } from 'react-apollo'
import gql from 'graphql-tag'
import PostVoteButton from './PostVoteButton'
function PostVoteUp ({ upvote, votes, id }) {
return (
<PostVoteButton
className='upvote'
onClickHandler={() => upvote(id, votes + 1)}
/>
)
}
const upvotePost = gql`
mutation updatePost($id: ID!, $votes: Int) {
updatePost(id: $id, votes: $votes) {
id
__typename
votes
}
}
`
export default graphql(upvotePost, {
props: ({ ownProps, mutate }) => ({
upvote: (id, votes) =>
mutate({
variables: { id, votes },
optimisticResponse: {
__typename: 'Mutation',
updatePost: {
__typename: 'Post',
id: ownProps.id,
votes: ownProps.votes + 1
}
}
})
})
})(PostVoteUp)

View file

@ -1,34 +1,24 @@
import { gql, graphql } from 'react-apollo'
import { graphql } from 'react-apollo'
import gql from 'graphql-tag'
import { allPosts, allPostsQueryVars } from './PostList'
function Submit ({ createPost }) {
function handleSubmit (e) {
e.preventDefault()
function handleSubmit (event) {
event.preventDefault()
let title = e.target.elements.title.value
let url = e.target.elements.url.value
const form = event.target
if (title === '' || url === '') {
window.alert('Both fields are required.')
return false
}
const formData = new window.FormData(form)
createPost(formData.get('title'), formData.get('url'))
// prepend http if missing from url
if (!url.match(/^[a-zA-Z]+:\/\//)) {
url = `http://${url}`
}
createPost(title, url)
// reset form
e.target.elements.title.value = ''
e.target.elements.url.value = ''
form.reset()
}
return (
<form onSubmit={handleSubmit}>
<h1>Submit</h1>
<input placeholder='title' name='title' />
<input placeholder='url' name='url' />
<input placeholder='title' name='title' type='text' required />
<input placeholder='url' name='url' type='url' required />
<button type='submit'>Submit</button>
<style jsx>{`
form {
@ -62,17 +52,23 @@ const createPost = gql`
export default graphql(createPost, {
props: ({ mutate }) => ({
createPost: (title, url) => mutate({
variables: { title, url },
updateQueries: {
allPosts: (previousResult, { mutationResult }) => {
const newPost = mutationResult.data.createPost
return Object.assign({}, previousResult, {
// Append the new post
allPosts: [newPost, ...previousResult.allPosts]
createPost: (title, url) =>
mutate({
variables: { title, url },
update: (proxy, { data: { createPost } }) => {
const data = proxy.readQuery({
query: allPosts,
variables: allPostsQueryVars
})
proxy.writeQuery({
query: allPosts,
data: {
...data,
allPosts: [createPost, ...data.allPosts]
},
variables: allPostsQueryVars
})
}
}
})
})
})
})(Submit)

View file

@ -1,4 +1,6 @@
import { ApolloClient, createNetworkInterface } from 'react-apollo'
import { ApolloClient } from 'apollo-client'
import { HttpLink } from 'apollo-link-http'
import { InMemoryCache } from 'apollo-cache-inmemory'
import fetch from 'isomorphic-unfetch'
let apolloClient = null
@ -8,28 +10,28 @@ if (!process.browser) {
global.fetch = fetch
}
function create () {
function create(initialState) {
return new ApolloClient({
connectToDevTools: process.browser,
ssrMode: !process.browser, // Disables forceFetch on the server (so queries are only run once)
networkInterface: createNetworkInterface({
link: new HttpLink({
uri: 'https://api.graph.cool/simple/v1/cixmkt2ul01q00122mksg82pn', // Server URL (must be absolute)
opts: { // Additional fetch() options like `credentials` or `headers`
credentials: 'same-origin'
}
})
credentials: 'same-origin' // Additional fetch() options like `credentials` or `headers`
}),
cache: new InMemoryCache().restore(initialState || {})
})
}
export default function initApollo () {
export default function initApollo(initialState) {
// Make sure to create a new client for every server-side request so that data
// isn't shared between connections (which would be bad)
if (!process.browser) {
return create()
return create(initialState)
}
// Reuse client on the client-side
if (!apolloClient) {
apolloClient = create()
apolloClient = create(initialState)
}
return apolloClient

View file

@ -12,27 +12,26 @@ if (process.browser && window.__REDUX_DEVTOOLS_EXTENSION__) {
function create (apollo, initialState = {}) {
return createStore(
combineReducers({ // Setup reducers
...reducers,
apollo: apollo.reducer()
...reducers
}),
initialState, // Hydrate the store with server-side data
compose(
applyMiddleware(apollo.middleware()), // Add additional middleware here
// Add additional middleware here
devtools
)
)
}
export default function initRedux (apollo, initialState) {
export default function initRedux (initialState) {
// Make sure to create a new store for every server-side request so that data
// isn't shared between connections (which would be bad)
if (!process.browser) {
return create(apollo, initialState)
return create(initialState)
}
// Reuse store on the client-side
if (!reduxStore) {
reduxStore = create(apollo, initialState)
reduxStore = create(initialState)
}
return reduxStore

View file

@ -1,5 +1,5 @@
export default {
example: (state = {}, { type, payload }) => {
redux: (state = {}, { type, payload }) => {
switch (type) {
case 'EXAMPLE_ACTION':
return {

View file

@ -6,19 +6,28 @@ import initApollo from './initApollo'
import initRedux from './initRedux'
// Gets the display name of a JSX component for dev tools
function getComponentDisplayName (Component) {
function getComponentDisplayName(Component) {
return Component.displayName || Component.name || 'Unknown'
}
export default ComposedComponent => {
return class WithData extends React.Component {
static displayName = `WithData(${getComponentDisplayName(ComposedComponent)})`
static displayName = `WithData(${getComponentDisplayName(
ComposedComponent
)})`
static propTypes = {
serverState: PropTypes.object.isRequired
stateApollo: PropTypes.object.isRequired
}
static async getInitialProps (ctx) {
let serverState = {}
static async getInitialProps(ctx) {
// Initial stateApollo with apollo (empty)
let stateApollo = {
apollo: {
data: {}
}
}
// Initial stateRedux with apollo (empty)
let stateRedux = {}
// Evaluate the composed component's getInitialProps()
let composedInitialProps = {}
@ -30,18 +39,21 @@ export default ComposedComponent => {
// and extract the resulting data
if (!process.browser) {
const apollo = initApollo()
const redux = initRedux(apollo)
// Provide the `url` prop data in case a GraphQL query uses it
const url = {query: ctx.query, pathname: ctx.pathname}
const redux = initRedux()
try {
// Run all GraphQL queries
await getDataFromTree(
// No need to use the Redux Provider
// because Apollo sets up the store for us
<ApolloProvider client={apollo} store={redux}>
<ComposedComponent url={url} {...composedInitialProps} />
</ApolloProvider>
<ApolloProvider client={apollo}>
<ComposedComponent {...composedInitialProps} />
</ApolloProvider>,
{
router: {
asPath: ctx.asPath,
pathname: ctx.pathname,
query: ctx.query
}
}
)
} catch (error) {
// Prevent Apollo Client GraphQL errors from crashing SSR.
@ -52,35 +64,33 @@ export default ComposedComponent => {
// head side effect therefore need to be cleared manually
Head.rewind()
// Extract query data from the store
const state = redux.getState()
// Extract query data from the Redux store
stateRedux = redux.getState()
// No need to include other initial Redux state because when it
// initialises on the client-side it'll create it again anyway
serverState = {
apollo: { // Only include the Apollo data state
data: state.apollo.data
// Extract query data from the Apollo store
stateApollo = {
apollo: {
data: apollo.cache.extract()
}
}
}
return {
serverState,
stateApollo,
stateRedux,
...composedInitialProps
}
}
constructor (props) {
constructor(props) {
super(props)
this.apollo = initApollo()
this.redux = initRedux(this.apollo, this.props.serverState)
this.apollo = initApollo(props.stateApollo.apollo.data)
this.redux = initRedux(props.stateRedux)
}
render () {
render() {
return (
// No need to use the Redux Provider
// because Apollo sets up the store for us
<ApolloProvider client={this.apollo} store={this.redux}>
<ApolloProvider client={this.apollo}>
<ComposedComponent {...this.props} />
</ApolloProvider>
)

View file

@ -7,15 +7,19 @@
"start": "NODE_ENV=production node server.js"
},
"dependencies": {
"graphql": "^0.9.3",
"apollo-cache-inmemory": "1.1.4",
"apollo-client-preset": "^1.0.4",
"graphql": "^0.11.7",
"graphql-anywhere": "^4.0.2",
"graphql-tag": "^2.5.0",
"isomorphic-unfetch": "^2.0.0",
"next": "latest",
"next-routes": "^1.1.0",
"prop-types": "^15.5.8",
"next-routes": "^1.2.0",
"prop-types": "^15.6.0",
"react": "^16.0.0",
"react-apollo": "^1.1.3",
"react-apollo": "^2.0.0",
"react-dom": "^16.0.0",
"redux": "^3.6.0"
"redux": "^3.7.0"
},
"author": "",
"license": "ISC"

View file

@ -1,9 +1,9 @@
import App from '../components/App'
import Header from '../components/Header'
export default (props) => (
export default () => (
<App>
<Header pathname={props.url.pathname} />
<Header />
<article>
<h1>The Idea Behind This Example</h1>
<p>

View file

@ -1,11 +1,12 @@
import withData from '../../lib/withData'
import App from '../../components/App'
import Header from '../../components/Header'
import Post from '../../components/Post'
import withData from '../../lib/withData'
export default withData((props) => (
<App>
<Header pathname={props.url.pathname} />
<Post id={props.url.query.id} />
<Header />
<Post />
</App>
))

View file

@ -1,12 +1,13 @@
import withData from '../../lib/withData'
import App from '../../components/App'
import Header from '../../components/Header'
import Submit from '../../components/Submit'
import PostList from '../../components/PostList'
import withData from '../../lib/withData'
export default withData((props) => (
export default withData(() => (
<App>
<Header pathname={props.url.pathname} />
<Header />
<Submit />
<PostList />
</App>

View file

@ -1,10 +1,11 @@
import App from '../components/App'
import Header from '../components/Header'
import withData from '../lib/withData'
export default withData((props) => (
import App from '../components/App'
import Header from '../components/Header'
export default withData(() => (
<App>
<Header pathname={props.url.pathname} />
<Header />
<h1>Welcome Home.</h1>
</App>
))

View file

@ -0,0 +1,8 @@
/**
* Parameterized Routing with next-route
*
* Benefits: Less code, and easily handles complex url structures
**/
const routes = (module.exports = require('next-routes')())
routes.add('blog/entry', '/blog/:id')

View file

@ -19,15 +19,7 @@ const next = require('next')
const port = parseInt(process.env.PORT, 10) || 3000
const dev = process.env.NODE_ENV !== 'production'
const app = next({dev})
/**
* Parameterized Routing with next-route
*
* Benefits: Less code, and easily handles complex url structures
*/
const routes = require('next-routes')()
routes.add('blog/entry', '/blog/:id')
const routes = require('./routes')
const handler = routes.getRequestHandler(app)
app.prepare().then(() => {

813
yarn.lock

File diff suppressed because it is too large Load diff