Creating a small text app

Present a keyword-in-context app without inactive controls on the lesson page

information visualization
Shiny
interactive apps
Learn how a Shiny app separates controls from server logic. Examine text search without starting the app on the lesson page.

Leila helps a local history class examine inaugural speeches. The class wants a search box that displays words near a match. Those results help readers decide which passages to examine.

Interactive app creation provides an interface for using an NLP function without writing R code. In Shiny, the UI is the page people see. The server is the R function that reads the input and computes the output.

This lesson builds the app object and examines its server logic during rendering. It does not start the app inside this static page. A printed text box would appear interactive but have no Shiny process to respond.

TipWhat you will learn

This lesson shows how to:

  • define a small Shiny UI, server function, and app object
  • provide a keyword-in-context search from quanteda
  • trim text input on the server
  • validate text input on the server
  • examine Shiny server logic with testServer()
  • show UI HTML as text without placing live controls on the page
  • state the limits of a server-only test

Build the search function first

The app will search paragraphs from the inaugural-address corpus bundled with quanteda. A keyword-in-context display, or KWIC, puts the matched word in the middle and a few nearby words on each side. Lesson 25 introduced KWIC as an inspection tool. Here an app provides access to that function.

library(dplyr)
library(purrr)
library(quanteda)
library(shiny)
library(stringr)
library(tibble)
library(tidyr)

source("R/inaugural-corpus.R")

paragraphs <- inaugural_paragraphs()
inaugural_tokens <- paragraphs |>
  select(paragraph_id, paragraph) |>
  tibble::deframe() |>
  corpus() |>
  tokens(remove_punct = TRUE)

find_in_context <- function(word, window = 5L) {
  kwic(
    inaugural_tokens,
    pattern = word,
    window = window,
    valuetype = "fixed",
    case_insensitive = TRUE
  ) |>
    as.data.frame() |>
    as_tibble() |>
    transmute(
      paragraph_id = docname,
      before = pre,
      keyword,
      after = post
    )
}
Inputs and package version for the small app
Item Value
paragraphs 1377
speeches 60
Shiny version 1.14.0
matching rule fixed, case-insensitive token matching

The important choice is valuetype = "fixed". Without it, quanteda treats the pattern as glob syntax by default. In this app, a typed asterisk is a literal asterisk, not a request to match every token.

Define the app without starting it

The UI has one visible label, one text input, a short status line, and a table for matches. The server trims the typed word before req() receives it. It then uses validate(need()) to display a message for invalid input.

max_word_chars <- 30L
max_rows_shown <- 8L

ui <- fluidPage(
  title = "Inaugural KWIC explorer",
  lang = "en",
  h2("Inaugural KWIC explorer"),
  p(
    "Type one word. Press Enter. You can use hyphenated words but not spaces."
  ),
  textInput(
    inputId = "word",
    label = "Word to find (one word, 30 characters or fewer)",
    value = "",
    placeholder = "liberty",
    updateOn = "blur"
  ),
  textOutput("status"),
  tableOutput("matches")
)

server <- function(input, output, session) {
  context_rows <- reactive({
    word <- str_trim(input$word %||% "")
    req(nzchar(word))
    validate(
      need(nchar(word) <= max_word_chars, "Enter at most 30 characters."),
      need(!str_detect(word, "\\s"), "Enter one word, with no spaces.")
    )

    find_in_context(word)
  })

  output$status <- renderText({
    rows <- context_rows()
    word <- str_trim(input$word %||% "")

    if (nrow(rows) == 0L) {
      paste0(
        "No word in these paragraphs matches '",
        word,
        "'. Marks at the start or end of a word, such as commas, are not part of the word here."
      )
    } else {
      shown <- min(nrow(rows), max_rows_shown)
      paste0(
        "Matches: ",
        nrow(rows),
        if_else(nrow(rows) == 1L, " line", " lines"),
        " for '",
        word,
        "'. Displayed lines: ",
        shown,
        "."
      )
    }
  })

  output$matches <- renderTable({
    context_rows() |>
      slice_head(n = max_rows_shown)
  })
}

app <- shinyApp(ui = ui, server = server)

reactive() manages the KWIC calculation so both outputs use the same current rows after input changes. %||% converts a missing input to empty text. req() stops without a displayed message until a word exists. validate(need()) stops with a visible message when input violates the app’s rules. renderText() and renderTable() convert R values to page text and a table.

fluidPage() builds the app page. h2() and p() add a heading and a paragraph. The title argument names the browser tab. lang = "en" identifies English for screen readers.

When Shiny serves the app, it places the title in the page head. It places the language on the <html> element. Neither appears in the printed body HTML. shinyApp() combines the UI and server for Shiny to serve together.

With updateOn = "blur", search executes only when the reader presses Enter or leaves the box. Results therefore do not receive a new announcement after every keystroke. testServer() cannot examine that browser behavior.

Typed text passes only through renderText() and renderTable(), not through raw HTML. These functions treat markup-like input as text. The server enforces length and spacing limits because users can bypass browser-side checks.

The server keeps the typed word only in the current Shiny session’s memory. It writes nothing to disk, prints nothing, logs nothing, and uses no cache. That does not make every hosted app private. A hosting service still has process logs, and a developer could add logging later. The boundary here is narrower: this example app does not store or log the user’s word.

Show the UI as text

The lesson page must not contain a live Shiny form. The chunk prints UI HTML as plain console output. The browser therefore shows text, not a working input.

ui_html <- paste(as.character(ui), collapse = "\n")
cat(ui_html)
<div class="container-fluid">
  <h2>Inaugural KWIC explorer</h2>
  <p>Type one word. Press Enter. You can use hyphenated words but not spaces.</p>
  <div class="form-group shiny-input-container">
    <label class="control-label" id="word-label" for="word">Word to find (one word, 30 characters or fewer)</label>
    <input id="word" type="text" class="shiny-input-text form-control" value="" placeholder="liberty" data-update-on="blur"/>
  </div>
  <div id="status" class="shiny-text-output"></div>
  <div id="matches" class="shiny-html-output shiny-table-output"></div>
</div>

The text output lets a reader examine the generated label and input. In the printed HTML, the label’s for="word" matches the input’s id="word". That link supplies the box’s name to a screen reader. The placeholder liberty is only a hint. This page does not accept typed input.

Examine server behavior

testServer() executes the server function without starting a browser. session$setInputs() simulates typed input. The test can then read the server’s outputs.

The helper starts a new test session for each typed value and records the result. tryCatch() records a stop, such as a failed need() check, instead of halting the page with an error.

run_server_case <- function(typed_text) {
  result <- NULL

  shiny::testServer(server, {
    session$setInputs(word = typed_text)

    status <- tryCatch(
      list(type = "output", text = output$status, class = NA_character_),
      error = \(error) {
        list(
          type = "condition",
          text = conditionMessage(error),
          class = class(error)[[1]]
        )
      }
    )
    found <- tryCatch(context_rows(), error = \(error) NULL)

    result <<- tibble(
      rows = if (is.null(found)) NA_integer_ else nrow(found),
      result_type = status$type,
      server_result = status$text,
      condition_class = status$class,
      rows_shown = list(if (is.null(found)) NULL else slice_head(found, n = max_rows_shown))
    )
  })

  result
}

server_cases <- tribble(
  ~case, ~typed_text,
  "known word", "liberty",
  "empty text", "",
  "three spaces", "   ",
  "two words", "free people",
  "over 30 characters", strrep("a", 31L),
  "literal asterisk", "*",
  "markup-like text", "<b>",
  "punctuated word", "liberty,",
  "no matching word", "zzzzzz"
)

case_results <- server_cases |>
  mutate(result = map(typed_text, run_server_case)) |>
  unnest(result)

The HTML table escapes recorded server results, so the browser displays markup characters as text. It does not interpret hostile-looking input as page markup.

A condition row means the server stopped instead of returning output. req() stops with an empty message, while validate(need()) stops with the message shown. β€œNot applicable” marks a Rows cell where the server stopped before returning any rows.

Server cases recorded by testServer during the render
Case Typed text Rows Result type Server result
known word liberty 118 output Matches: 118 lines for 'liberty'. Displayed lines: 8.
empty text "" not applicable condition
three spaces "   " not applicable condition
two words free people not applicable condition Enter one word, with no spaces.
over 30 characters aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa not applicable condition Enter at most 30 characters.
literal asterisk * 0 output No word in these paragraphs matches '*'. Marks at the start or end of a word, such as commas, are not part of the word here.
markup-like text <b> 0 output No word in these paragraphs matches '<b>'. Marks at the start or end of a word, such as commas, are not part of the word here.
punctuated word liberty, 0 output No word in these paragraphs matches 'liberty,'. Marks at the start or end of a word, such as commas, are not part of the word here.
no matching word zzzzzz 0 output No word in these paragraphs matches 'zzzzzz'. Marks at the start or end of a word, such as commas, are not part of the word here.

The table below is the first app table for liberty, captured from the same server test. It shows the first eight KWIC rows even though the full result has 118 lines.

First eight server-returned KWIC rows for liberty
Paragraph ID Before Keyword After
1789-Washington-p03 of the sacred fire of liberty and the destiny of the
1797-Adams-p04 and secure the blessings of liberty The public disquisitions discussions and
1797-Adams-p09 ardent patriotism and love of liberty to independence and peace to
1797-Adams-p13 of the world of virtuous liberty continue His blessing upon this
1801-Jefferson-p02 harmony and affection without which liberty and even life itself are
1801-Jefferson-p02 blood and slaughter his long-lost liberty it was not wonderful that
1801-Jefferson-p04 which alone leads to peace liberty and safety
1805-Jefferson-p08 breathing an ardent love of liberty and independence and occupying a

Limits of server tests

Server tests do not exercise layout, browser JavaScript, focus order, keyboard operation, or screen-reader output. They cannot examine Shiny’s live-region announcements in a browser. An app for public users requires browser tests and manual accessibility checks.

To start the app locally:

  1. Open an R session at the project root.
  2. Execute the chunks in β€œBuild the search function first”.
  3. Execute the chunks in β€œDefine the app without starting it”.
  4. Execute shiny::runApp(app).

The app remains available only while that R session serves it.

What question does this app answer?

The app helps a reader examine context lines. It does not say a word is important, calculate a topic estimate, or deploy a model. KWIC counts depend on the token rules, punctuation handling, and case policy. Here the unit is the token stream created from reconstructed paragraphs, and matching is fixed and case-insensitive.

What to remember

  • A Shiny app has a UI object, a server function, and an app object.
  • A static lesson page should not print a live-looking Shiny input with no server behind it.
  • Trim text before req() so whitespace-only input pauses instead of searching.
  • Use server-side need() checks for invalid values such as spaces or too many characters.
  • Fixed matching keeps * and ( from becoming pattern syntax.
  • testServer() examines server logic, not the browser experience.

The static lesson page demonstrates app construction and server tests. Start the interactive app in a local R session.

Sources