Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/trambarhq/relaks-hacker-news-example

An example of using Relaks to create a Hacker News reader
https://github.com/trambarhq/relaks-hacker-news-example

async asynchronous await hacker-news hn preact promise react relaks

Last synced: 1 day ago
JSON representation

An example of using Relaks to create a Hacker News reader

Awesome Lists containing this project

README

        

# Relaks Hacker News Example

The unopinionated nature of [Relaks](https://github.com/trambarhq/relaks) makes it especially useful during the prototyping phrase of development. In this example, we're going to build a quick-and-dirty [Hacker News](https://news.ycombinator.com/) reader. We don't want to spend time on software architecture. We just want a working demo to show people. The focus will be squarely on the user interface.

[Here's the end result](https://trambar.io/examples/hacker-news/).

[![Screenshot](docs/img/screenshot.png)](https://trambar.io/examples/hacker-news/)

(In case you're wondering: Yes, the UI is meant as a joke :-)

## Data source

The code for data retrieval is contained in [hacker-news.js](https://github.com/trambarhq/relaks-hacker-news-example/blob/master/src/hacker-news.js). It's very primitive:

```javascript
const baseURL = 'https://hacker-news.firebaseio.com/v0'
const cache = {};

async function get(uri) {
let promise = cache[uri];
if (!promise) {
promise = cache[uri] = fetchJSON(baseURL + uri);
}
return promise;
}

async function fetchJSON(url) {
const response = await fetch(url);
return response.json();
}

export {
get
};
```

Just a function (rather poorly named) that retrieves a JSON object from Hacker News. We aren't familiar with the [Hacker News API](https://github.com/HackerNews/API) at this point. We aren't even sure if our approach is viable. Conceivably, assessing the API directly from the client side might be too slow. It doesn't make sense therefore to build something sophisticated.

## FrontEnd

Per usual, `FrontEnd` ([front-end.jsx](https://github.com/trambarhq/relaks-hacker-news-example/blob/master/src/front-end.jsx)) is the front-end's root node. It's a regular React component. Its source code is listed below:

```javascript
import React, { useState } from 'react';
import { StoryList } from 'story-list';

import 'style.scss';

export function FrontEnd(props) {
const [ storyType, setStoryType ] = useState(localStorage.storyType || 'topstories');

const handleClick = (evt) => {
const target = evt.currentTarget;
const type = target.getAttribute('data-value');
setStoryType(type);
localStorage.storyType = type;
};

return (





Top Stories


Best Stories


Ask Stories


Show Stories


Job Stories





);
}

function Button(props) {
const { value, children, onClick } = props;
const btnClassNames = [ 'button' ];
const iconClassNames = [ 'icon', 'fa-heart' ];
if (props.value === props.selected) {
iconClassNames.push('fas') ;
btnClassNames.push('selected');
} else {
iconClassNames.push('far');
}
return (


{children}

)
}
```

Pretty standard React code. The method renders a nav bar and a story list, which could be of different types ("top", "best", "job", etc.). One notable detail is the use of a key on `StoryList`. This will be addressed [later](#key-usage).

## StoryList

`StoryList` ([story-list.jsx](https://github.com/trambarhq/relaks-hacker-news-example/blob/master/src/story-list.jsx)) is a Relaks component. It uses the `useProgress` hook to perform progressive rendering. It accepts `type` as a prop and retrieves stories of that type from HN.

```javascript
import React from 'react';
import { useProgress } from 'relaks';
import { StoryView } from 'story-view';
import { get } from 'hacker-news';

export async function StoryList(props) {
const { type } = props;
const [ show ] = useProgress();
const stories = [];

render();
const storyIDs = await get(`/${type}.json`);
for (let i = 0, n = 5; i < storyIDs.length; i += n) {
const idChunk = storyIDs.slice(i, i + n);
const storyChunk = await Promise.all(idChunk.map(async (id) => {
return get(`/item/${id}.json`);
}));
for (let story of storyChunk) {
stories.push(story);
}
render();
}

function render() {
show(


{stories.map(renderStory)}

);
}

function renderStory(story, i) {
if (story.deleted) {
return null;
}
return ;
}
}
```

We first retrieve a list of story IDs (e.g. [/topstories.json](https://hacker-news.firebaseio.com/v0/topstories.json)). The list can contain upwards of 500 IDs. The API only permits the retrieval of a single story at a time. We obviously don't want to wait for 500 HTTP requests to finish before showing something. So we break the list into chunks of five and ask for redraw after each chunk is fetched.

## StoryView

`StoryView` ([story-view.jsx](https://github.com/trambarhq/relaks-hacker-news-example/blob/master/src/story-view.jsx)) is another Relaks component. Async handling is needed because poll stories have additional parts that need to be downloaded. That only occupies a small part of its code though. The rest is standard React UI code.

```javascript
import React, { useState } from 'react';
import { useProgress } from 'relaks';
import { CommentList } from 'comment-list';
import { get } from 'hacker-news';

async function StoryView(props) {
const { story } = props;
const [ showingComments, showComments ] = useState(false);
const [ renderingComments, renderComments ] = useState(false);
const [ show ] = useProgress();
const parts = [];

render();
if (story.parts && story.parts.length > 0) {
const idChunk = story.parts;
const partChunk = await Promise.all(idChunk.map((id) => {
return get(`/item/${id}.json`);
}));
for (let part of partChunk) {
parts.push(part);
}
render();
}

function render() {
show(



{story.title} by {story.by}



{renderDecorativeImage()}
{renderText()}
{renderParts()}
{renderURL()}



{renderCommentCount()}
{renderCommentList()}


);
}

function renderDecorativeImage() {
const index = story.id % decorativeImages.length;
const image = decorativeImages[index];
if (!(story.text || '').trim() && !story.url && (!story.parts || story.parts.length === 0)) {
return (




);
} else {
return
}
}

function renderText() {
return

;
}

function renderParts() {
if (!story.parts || story.parts.length === 0) {
return null;
}
return

    {story.parts.map(renderPart)}
;
}

function renderPart(id, i) {
const part = (parts) ? parts[index] : null;
if (part) {
return

  • ({part.score} votes)
  • ;
    } else {
    return
  • ...
  • ;
    }
    }

    function renderURL() {
    return {story.url};
    }

    function renderCommentCount() {
    const count = (story.kids) ? story.kids.length : 0;
    const label = `${count} comment` + (count === 1 ? '' : 's');
    const classNames = [ 'comment-bar' ];
    let onClick;
    if (count > 0) {
    classNames.push('clickable');
    onClick = (evt) => {
    if (showingComments) {
    showComments(false);
    } else {
    renderComments(true);
    showComments(true);
    }
    };
    }
    return

    {label}
    ;
    }

    function renderCommentList() {
    let comments;
    if (renderingComments) {
    comments = ;
    }
    const classNames = [ 'comment-container' ];
    let onTransitionEnd
    if (showingComments) {
    classNames.push('open');
    } else {
    if (renderingComments) {
    onTransitionEnd = (evt) => {
    renderComments(false);
    };
    }
    }
    return (


    {comments}

    );
    }
    }

    function HTML(props) {
    const markup = { __html: props.markup };
    return ;
    }

    const decorativeImages = [
    require('../img/kitty-1.png'),
    require('../img/kitty-2.png'),
    require('../img/kitty-3.png'),
    require('../img/kitty-4.png'),
    require('../img/kitty-5.png'),
    require('../img/kitty-6.png'),
    require('../img/kitty-7.png'),
    ];
    const extraDecorativeImage = require('../img/kitty-8.png');
    ```

    The code above should be largely self-explanatory. Of the helper functions, `renderCommentList()` is the only one that warrants a closer look:

    ```javascript
    function renderCommentList() {
    let comments;
    if (renderingComments) {
    comments = ;
    }
    const classNames = [ 'comment-container' ];
    let onTransitionEnd
    if (showingComments) {
    classNames.push('open');
    } else {
    if (renderingComments) {
    onTransitionEnd = (evt) => {
    renderComments(false);
    };
    }
    }
    return (


    {comments}

    );
    }
    ```

    Comments are not shown initially. They appear when the user clicks on the bar. Two state variables are used to track this: `showingComments` and `renderingComments`. The second one is needed due to transition effect. We have to continue to render `CommentList` while the container div is collapsing. Only after the transition has ended can we stop rendering it.

    ## CommentList

    `CommentList` ([comment-list.jsx](https://github.com/trambarhq/relaks-hacker-news-example/blob/master/src/comment-list.jsx)) functions largely like `StoryList`. Its code was, in fact, created by copy-and-pasting. The component receives `commentIDs` and `replies` as props. The latter is a boolean that indicates whether the list contains replies to comments. `StoryView` sets this to `false`.

    ```javascript
    import React from 'react';
    import { useProgress } from 'relaks';
    import { CommentView } from 'comment-view';
    import { get } from 'hacker-news';

    export async function CommentList(props) {
    const { commentIDs, replies } = props;
    const [ show ] = useProgress();
    const comments = [];

    render();
    for (let i = 0, n = 5; i < commentIDs.length; i += n) {
    const idChunk = commentIDs.slice(i, i + n);
    const commentChunk = await Promise.all(idChunk.map(async (id) => {
    return get(`/item/${id}.json`);
    }));
    for (let comment of commentChunk) {
    comments.push(comment);
    }
    render();
    }

    function render() {
    show(


    {commentIDs.map(renderComment)}

    );
    }

    function renderComment(commentID, i) {
    return ;
    }
    }
    ```

    The rendering code is slightly different here. Instead of loop through the list of comment objects, we loop through the list of comment IDs. This allows us to draw placeholders for the comments while they're loading.

    ## CommentView

    `CommentView` ([comment-view.jsx](https://github.com/trambarhq/relaks-hacker-news-example/blob/master/src/comment-view.jsx)) is a normal React component. It receives `comment` and `reply` as props. The latter indicates whether the comment is a reply to a comment.

    ```javascript
    import React from 'react';
    import { CommentList } from 'comment-list';

    export function CommentView(props) {
    const { comment, reply } = props;
    const iconClassNames = [ 'fa-heart', (reply) ? 'far' : 'fas' ];
    let author, text;
    if (comment) {
    if (!comment.deleted) {
    author = `${comment.by}:`;
    text =